1cbd41b8d30959ee5db93b70a291d53ab2b7deeb
[books.alexwlchan.net] / scripts / render_html.py
1 #!/usr/bin/env python3
2
3 import datetime
4 import hashlib
5 import itertools
6 import os
7 import pathlib
8 import re
9 import subprocess
10 import sys
11
12 import attr
13 import bs4
14 import cssmin
15 import frontmatter
16 from jinja2 import Environment, FileSystemLoader, select_autoescape
17 import markdown
18 from markdown.extensions.smarty import SmartyExtension
19 import smartypants
20
21 from generate_bookshelf import create_shelf_data_uri
22 from tint_colors import get_tint_colors, store_tint_color
23
24
25 def rsync(dir1, dir2):
26     subprocess.check_call(["rsync", "--recursive", "--delete", dir1, dir2])
27
28
29 @attr.s
30 class Book:
31     title = attr.ib()
32     author = attr.ib()
33     publication_year = attr.ib()
34     cover_image = attr.ib(default="")
35     cover_desc = attr.ib(default="")
36
37     isbn10 = attr.ib(default="")
38     isbn13 = attr.ib(default="")
39
40
41 @attr.s
42 class Review:
43     date_read = attr.ib()
44     text = attr.ib()
45     format = attr.ib(default=None)
46     rating = attr.ib(default=None)
47     did_not_finish = attr.ib(default=False)
48
49
50 @attr.s
51 class ReviewEntry:
52     path = attr.ib()
53     book = attr.ib()
54     review = attr.ib()
55
56     def out_path(self):
57         name = self.path.with_suffix("").name
58         return pathlib.Path(f"reviews/{name}")
59
60
61 def get_review_entry_from_path(path):
62     post = frontmatter.load(path)
63
64     kwargs = {}
65     for attr_name in Book.__attrs_attrs__:
66         try:
67             kwargs[attr_name.name] = post["book"][attr_name.name]
68         except KeyError:
69             pass
70
71     book = Book(**kwargs)
72
73     review = Review(**post["review"], text=post.content)
74
75     return ReviewEntry(path=path, book=book, review=review)
76
77
78 @attr.s
79 class CurrentlyReading:
80     text = attr.ib()
81
82
83 @attr.s
84 class CurrentlyReadingEntry:
85     path = attr.ib()
86     book = attr.ib()
87     reading = attr.ib()
88
89
90 def get_reading_entry_from_path(path):
91     post = frontmatter.load(path)
92
93     book = Book(**post["book"])
94     reading = CurrentlyReading(text=post.content)
95
96     return CurrentlyReadingEntry(path=path, book=book, reading=reading)
97
98
99 def _parse_date(value):
100     if isinstance(value, datetime.date):
101         return value
102     else:
103         return datetime.datetime.strptime(value, "%Y-%m-%d").date()
104
105
106 @attr.s
107 class Plan:
108     text = attr.ib()
109     date_added = attr.ib(converter=_parse_date)
110
111
112 @attr.s
113 class PlanEntry:
114     path = attr.ib()
115     book = attr.ib()
116     plan = attr.ib()
117
118
119 def get_plan_entry_from_path(path):
120     post = frontmatter.load(path)
121
122     book = Book(**post["book"])
123     plan = Plan(date_added=post["plan"]["date_added"], text=post.content)
124
125     return PlanEntry(path=path, book=book, plan=plan)
126
127
128 def get_entries(dirpath, constructor):
129     for dirpath, _, filenames in os.walk(dirpath):
130         for f in filenames:
131             if not f.endswith(".md"):
132                 continue
133
134             path = pathlib.Path(dirpath) / f
135
136             try:
137                 yield constructor(path)
138             except Exception:
139                 print(f"Error parsing {path}", file=sys.stderr)
140                 raise
141
142
143 def render_markdown(text):
144     return markdown.markdown(text, extensions=[SmartyExtension()])
145
146
147 def render_date(date_value):
148     if isinstance(date_value, datetime.date):
149         return date_value.strftime("%-d %B %Y")
150
151     date_match = re.match(
152         r"^(?P<year>\d{4})-(?P<month>\d{2})(?:-(?P<day>\d{2}))?$", date_value
153     )
154     assert date_match is not None, date_value
155
156     date_obj = datetime.datetime(
157         year=int(date_match.group("year")),
158         month=int(date_match.group("month")),
159         day=int(date_match.group("day") or "1"),
160     )
161
162     if date_match.group("day"):
163         return render_date(date_obj)
164     else:
165         return date_obj.strftime("%B %Y")
166
167
168 def save_html(template, out_name="", **kwargs):
169     html = template.render(**kwargs)
170     out_path = pathlib.Path("_html") / out_name / "index.html"
171     out_path.parent.mkdir(exist_ok=True, parents=True)
172     html_str = htmlmin.minify(html)
173
174     soup = bs4.BeautifulSoup(html_str, "html.parser")
175
176     # Minify the CSS in all inline <style> tags.
177     for style_tag in soup.find_all("style"):
178         style_tag.string = cssmin.cssmin(style_tag.string)
179
180     # Remove any comments
181     for comment in soup(text=lambda text: isinstance(text, bs4.Comment)):
182         comment.extract()
183
184     out_path.write_text(str(soup))
185
186
187 def _create_new_thumbnail(src_path, dst_path):
188     dst_path.parent.mkdir(exist_ok=True, parents=True)
189
190     # Thumbnails are 240x240 max, then 2x for retina displays
191     subprocess.check_call([
192         "convert", src_path, "-resize", "480x480>", dst_path
193     ])
194
195
196 def thumbnail_1x(name):
197     pth = pathlib.Path(name)
198     return pth.stem + "_1x" + pth.suffix
199
200
201 def _create_new_square(src_path, square_path):
202     square_path.parent.mkdir(exist_ok=True, parents=True)
203
204     subprocess.check_call([
205         "convert",
206         src_path, "-resize", "240x240", "-gravity", "center", "-background", "white", "-extent", "240x240", square_path
207     ])
208
209
210 def create_thumbnails():
211     for image_name in os.listdir("src/covers"):
212         if image_name == ".DS_Store":
213             continue
214
215         src_path = pathlib.Path("src/covers") / image_name
216         dst_path = pathlib.Path("_html/thumbnails") / image_name
217
218         if not dst_path.exists():
219             _create_new_thumbnail(src_path, dst_path)
220         elif src_path.stat().st_mtime > dst_path.stat().st_mtime:
221             _create_new_thumbnail(src_path, dst_path)
222
223         square_path = pathlib.Path("_html/squares") / image_name
224
225         if not square_path.exists():
226             _create_new_square(src_path, square_path)
227         elif src_path.stat().st_mtime > square_path.stat().st_mtime:
228             _create_new_square(src_path, square_path)
229
230         store_tint_color(dst_path)
231
232
233 CSS_HASH = hashlib.md5(open('static/style.css', 'rb').read()).hexdigest()
234
235
236 def css_hash(_):
237     return f"md5:{CSS_HASH}"
238
239
240 def main():
241     env = Environment(
242         loader=FileSystemLoader("templates"),
243         autoescape=select_autoescape(["html", "xml"]),
244     )
245
246     env.filters["render_markdown"] = render_markdown
247     env.filters["render_date"] = render_date
248     env.filters["smartypants"] = smartypants.smartypants
249     env.filters["thumbnail_1x"] = thumbnail_1x
250     env.filters["css_hash"] = css_hash
251     env.filters["create_shelf_data_uri"] = create_shelf_data_uri
252
253     create_thumbnails()
254
255     tint_colors = get_tint_colors()
256
257     rsync("src/covers/", "_html/covers/")
258     rsync("static/", "_html/static/")
259
260     # Render the "all reviews page"
261
262     all_reviews = list(
263         get_entries(dirpath="src/reviews", constructor=get_review_entry_from_path)
264     )
265     all_reviews = sorted(
266         all_reviews, key=lambda rev: str(rev.review.date_read), reverse=True
267     )
268
269     for review_entry in all_reviews:
270         save_html(
271             template=env.get_template("review.html"),
272             out_name=review_entry.out_path(),
273             review_entry=review_entry,
274             title=f"My review of {review_entry.book.title}",
275             tint_colors=tint_colors
276         )
277
278     save_html(
279         template=env.get_template("list_reviews.html"),
280         out_name="reviews",
281         all_reviews=[
282             (year, list(reviews))
283             for (year, reviews) in itertools.groupby(
284                 all_reviews, key=lambda rev: str(rev.review.date_read)[:4]
285             )
286         ],
287         title="books i’ve read",
288         this_year=str(datetime.datetime.now().year),
289         tint_colors=tint_colors
290     )
291
292     # Render the "currently reading" page
293
294     all_reading = list(
295         get_entries(
296             dirpath="src/currently_reading", constructor=get_reading_entry_from_path
297         )
298     )
299
300     save_html(
301         template=env.get_template("list_reading.html"),
302         out_name="reading",
303         all_reading=all_reading,
304         title="books i’m currently reading",
305         tint_colors=tint_colors
306     )
307
308     # Render the "want to read" page
309
310     all_plans = list(
311         get_entries(dirpath="src/plans", constructor=get_plan_entry_from_path)
312     )
313
314     all_plans = sorted(all_plans, key=lambda plan: plan.plan.date_added, reverse=True)
315
316     save_html(
317         template=env.get_template("list_plans.html"),
318         out_name="to-read",
319         all_plans=all_plans,
320         title="books i want to read",
321         tint_colors=tint_colors,
322     )
323
324     # Render the "never going to read this page"
325
326     all_retired = list(
327         get_entries(dirpath="src/will_never_read", constructor=get_plan_entry_from_path)
328     )
329
330     all_retired = sorted(
331         all_retired, key=lambda plan: plan.plan.date_added, reverse=True
332     )
333
334     save_html(
335         template=env.get_template("list_will_never_read.html"),
336         out_name="will-never-read",
337         all_retired=all_retired,
338         title="books i&rsquo;m never going to read",
339         tint_colors=tint_colors
340     )
341
342     # Render the front page
343
344     save_html(
345         template=env.get_template("index.html"),
346         text=open("src/index.md").read(),
347         reviews=all_reviews[:5],
348         tint_colors=tint_colors
349     )
350
351     print("✨ Rendered HTML files to _html ✨")
352
353
354 if __name__ == "__main__":
355     main()