573c14de071b50dfd2b6f4449740d9108e1be315
[books.alexwlchan.net] / scripts / render_html.py
1 #!/usr/bin/env python
2
3 import datetime
4 import itertools
5 import os
6 import pathlib
7 import re
8 import subprocess
9 import sys
10
11 import attr
12 import frontmatter
13 from jinja2 import Environment, FileSystemLoader, select_autoescape
14 import markdown
15 from markdown.extensions.smarty import SmartyExtension
16 import smartypants
17
18
19 def rsync(dir1, dir2):
20     subprocess.check_call(["rsync", "--recursive", "--delete", dir1, dir2])
21
22
23 @attr.s
24 class Book:
25     title = attr.ib()
26     author = attr.ib()
27     publication_year = attr.ib()
28     cover_image = attr.ib()
29     cover_desc = attr.ib(default="")
30
31     isbn10 = attr.ib(default="")
32     isbn13 = attr.ib(default="")
33
34
35 @attr.s
36 class Review:
37     date_read = attr.ib()
38     text = attr.ib()
39     format = attr.ib(default=None)
40     rating = attr.ib(default=None)
41     did_not_finish = attr.ib(default=False)
42
43
44 @attr.s
45 class ReviewEntry:
46     path = attr.ib()
47     book = attr.ib()
48     review = attr.ib()
49
50     def out_path(self):
51         name = self.path.with_suffix("").name
52         return pathlib.Path(f"reviews/{name}")
53
54
55 def get_review_entry_from_path(path):
56     post = frontmatter.load(path)
57
58     book = Book(**post["book"])
59     review = Review(**post["review"], text=post.content)
60
61     return ReviewEntry(path=path, book=book, review=review)
62
63
64 @attr.s
65 class CurrentlyReading:
66     text = attr.ib()
67
68
69 @attr.s
70 class CurrentlyReadingEntry:
71     path = attr.ib()
72     book = attr.ib()
73     reading = attr.ib()
74
75
76 def get_reading_entry_from_path(path):
77     post = frontmatter.load(path)
78
79     book = Book(**post["book"])
80     reading = CurrentlyReading(text=post.content)
81
82     return CurrentlyReadingEntry(path=path, book=book, reading=reading)
83
84
85 def _parse_date(value):
86     if isinstance(value, datetime.date):
87         return value
88     else:
89         return datetime.datetime.strptime(value, "%Y-%m-%d").date()
90
91
92 @attr.s
93 class Plan:
94     text = attr.ib()
95     date_added = attr.ib(converter=_parse_date)
96
97
98 @attr.s
99 class PlanEntry:
100     path = attr.ib()
101     book = attr.ib()
102     plan = attr.ib()
103
104
105 def get_plan_entry_from_path(path):
106     post = frontmatter.load(path)
107
108     book = Book(**post["book"])
109     plan = Plan(**post["plan"], text=post.content)
110
111     return PlanEntry(path=path, book=book, plan=plan)
112
113
114 def get_entries(dirpath, constructor):
115     for dirpath, _, filenames in os.walk(dirpath):
116         for f in filenames:
117             if not f.endswith(".md"):
118                 continue
119
120             path = pathlib.Path(dirpath) / f
121
122             try:
123                 yield constructor(path)
124             except Exception:
125                 print(f"Error parsing {path}", file=sys.stderr)
126                 raise
127
128
129 def render_markdown(text):
130     return markdown.markdown(text, extensions=[SmartyExtension()])
131
132
133 def render_date(date_value):
134     if isinstance(date_value, datetime.date):
135         return date_value.strftime("%d %B %Y")
136
137     date_match = re.match(
138         r"^(?P<year>\d{4})-(?P<month>\d{2})(?:-(?P<day>\d{2}))?$", date_value
139     )
140     assert date_match is not None, date_value
141
142     date_obj = datetime.datetime(
143         year=int(date_match.group("year")),
144         month=int(date_match.group("month")),
145         day=int(date_match.group("day") or "1"),
146     )
147
148     if date_match.group("day"):
149         return date_obj.strftime("%-d %B %Y")
150     else:
151         return date_obj.strftime("%B %Y")
152
153
154 def render_individual_review(env, *, review_entry):
155     template = env.get_template("review.html")
156     html = template.render(
157         review_entry=review_entry, title=f"My review of {review_entry.book.title}"
158     )
159
160     out_name = review_entry.out_path() / "index.html"
161     out_path = pathlib.Path("_html") / out_name
162     out_path.parent.mkdir(exist_ok=True, parents=True)
163     out_path.write_text(html)
164
165
166 def main():
167     env = Environment(
168         loader=FileSystemLoader("templates"),
169         autoescape=select_autoescape(["html", "xml"]),
170     )
171
172     env.filters["render_markdown"] = render_markdown
173     env.filters["render_date"] = render_date
174     env.filters["smartypants"] = smartypants.smartypants
175
176     rsync("src/covers/", "_html/covers/")
177     rsync("static/", "_html/static/")
178
179     # Render the "all reviews page"
180
181     all_reviews = list(
182         get_entries(dirpath="src/reviews", constructor=get_review_entry_from_path)
183     )
184     all_reviews = sorted(
185         all_reviews, key=lambda rev: str(rev.review.date_read), reverse=True
186     )
187
188     for review_entry in all_reviews:
189         render_individual_review(env, review_entry=review_entry)
190
191     template = env.get_template("list_reviews.html")
192     html = template.render(
193         all_reviews=[
194             (year, list(reviews))
195             for (year, reviews) in itertools.groupby(
196                 all_reviews, key=lambda rev: str(rev.review.date_read)[:4]
197             )
198         ],
199         title="books i’ve read",
200         this_year=str(datetime.datetime.now().year),
201     )
202
203     out_path = pathlib.Path("_html") / "reviews/index.html"
204     out_path.write_text(html)
205
206     # Render the "currently reading" page
207
208     all_reading = list(
209         get_entries(
210             dirpath="src/currently_reading", constructor=get_reading_entry_from_path
211         )
212     )
213
214     template = env.get_template("list_reading.html")
215     html = template.render(all_reading=all_reading, title="books i’m currently reading")
216
217     out_path = pathlib.Path("_html") / "reading/index.html"
218     out_path.parent.mkdir(exist_ok=True, parents=True)
219     out_path.write_text(html)
220
221     # Render the "want to read" page
222
223     all_plans = list(
224         get_entries(dirpath="src/plans", constructor=get_plan_entry_from_path)
225     )
226
227     all_plans = sorted(all_plans, key=lambda plan: plan.plan.date_added)
228
229     template = env.get_template("list_plans.html")
230     html = template.render(all_plans=all_plans, title="books i want to read")
231
232     out_path = pathlib.Path("_html") / "to-read/index.html"
233     out_path.parent.mkdir(exist_ok=True, parents=True)
234     out_path.write_text(html)
235
236     # Render the front page
237
238     index_template = env.get_template("index.html")
239     html = index_template.render(text=open("src/index.md").read())
240
241     index_path = pathlib.Path("_html") / "index.html"
242     index_path.write_text(html)
243
244     print("✨ Rendered HTML files to _html ✨")
245
246
247 if __name__ == "__main__":
248     main()