Markdown blog on Flask with Vite-built assets and WeasyPrint PDF export, rewritten from the Wagtail build that preceded it.
blogdockerflaskmarkdownpythonself-hostedvite
1"""
2app.py
3
4A simple Flask blog powered by markdown files.
5"""
6
7import html
8import json
9import math
10import os
11import random
12from datetime import date, datetime
13
14import mistune
15from flask import (
16 Flask,
17 Response,
18 abort,
19 jsonify,
20 redirect,
21 render_template,
22 request,
23 send_from_directory,
24 url_for,
25)
26from urllib.parse import unquote, urlparse
27
28from weasyprint import HTML, default_url_fetcher
29
30app = Flask(__name__)
31app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 31536000
32
33CONTENT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "content")
34MANIFEST_PATH = os.path.join(app.static_folder, ".vite", "manifest.json")
35
36_manifest_cache = None
37
38
39def get_vite_manifest():
40 global _manifest_cache
41 if _manifest_cache is None or app.debug:
42 with open(MANIFEST_PATH) as f:
43 _manifest_cache = json.load(f)
44 return _manifest_cache
45
46
47def vite_asset(entry, kind="file"):
48 manifest = get_vite_manifest()
49 chunk = manifest.get(entry)
50 if chunk:
51 if kind == "css" and "css" in chunk:
52 return "/static/" + chunk["css"][0]
53 return "/static/" + chunk["file"]
54 return url_for("static", filename=entry)
55
56
57app.jinja_env.globals["vite_asset"] = vite_asset
58
59
60# -- Markdown rendering -------------------------------------------------------
61
62
63class BlogRenderer(mistune.HTMLRenderer):
64 def block_code(self, code, info=None, **attrs):
65 lang = info or ""
66 if lang == "html":
67 lang = "htmlmixed"
68 escaped = html.escape(code)
69 return (
70 f'<div class="block-code">'
71 f'<textarea data-language="{lang}">{escaped}</textarea>'
72 f"</div>\n"
73 )
74
75 def image(self, text, url, title=None):
76 if url.startswith("images/"):
77 url = url[len("images/"):]
78 return (
79 f'<div class="block-image">'
80 f'<img src="/content/images/{url}" class="rounded" alt="{text}">'
81 f"</div>\n"
82 )
83
84 def paragraph(self, text):
85 if text.strip().startswith('<div class="block-image">'):
86 return text
87 return f'<div class="block-rich-text"><p>{text}</p></div>\n'
88
89 def list(self, text, ordered, **attrs):
90 tag = "ol" if ordered else "ul"
91 return f'<div class="block-rich-text"><{tag}>{text}</{tag}></div>\n'
92
93 def heading(self, text, level, **attrs):
94 return f'<div class="block-rich-text"><h{level}>{text}</h{level}></div>\n'
95
96 def block_quote(self, text):
97 return f'<div class="block-rich-text"><blockquote>{text}</blockquote></div>\n'
98
99 def thematic_break(self):
100 return '<div class="block-rich-text"><hr></div>\n'
101
102
103markdown = mistune.create_markdown(renderer=BlogRenderer(), plugins=["strikethrough"])
104
105
106class PDFRenderer(BlogRenderer):
107 def block_code(self, code, info=None, **attrs):
108 lang = info or ""
109 escaped = html.escape(code)
110 return (
111 f'<div class="block-code">'
112 f'<pre><code class="language-{lang}">{escaped}</code></pre>'
113 f"</div>\n"
114 )
115
116
117markdown_pdf = mistune.create_markdown(renderer=PDFRenderer(), plugins=["strikethrough"])
118
119
120# -- Content loading -----------------------------------------------------------
121
122
123def parse_frontmatter(text):
124 """Parse YAML-like frontmatter from a markdown file."""
125 if not text.startswith("---"):
126 return {}, text
127 end = text.find("---", 3)
128 if end == -1:
129 return {}, text
130 meta = {}
131 for line in text[3:end].strip().split("\n"):
132 if ": " in line:
133 key, value = line.split(": ", 1)
134 meta[key.strip()] = value.strip()
135 body = text[end + 3 :].strip()
136 return meta, body
137
138
139def load_posts():
140 """Load all markdown posts from content/posts/."""
141 posts = []
142 posts_dir = os.path.join(CONTENT_DIR, "posts")
143 if not os.path.exists(posts_dir):
144 return posts
145
146 for filename in os.listdir(posts_dir):
147 if not filename.endswith(".md"):
148 continue
149 filepath = os.path.join(posts_dir, filename)
150 with open(filepath) as f:
151 text = f.read()
152 meta, body = parse_frontmatter(text)
153
154 tags = [t.strip() for t in meta.get("tags", "").split(",") if t.strip()]
155 post_date = meta.get("date", "")
156 publish_date = meta.get("publish_date", post_date)
157
158 post = {
159 "filename": filename,
160 "title": meta.get("title", ""),
161 "slug": meta.get("slug", filename[:-3]),
162 "date": post_date,
163 "publish_date": publish_date,
164 "tags": tags,
165 "description": meta.get("description", ""),
166 "cover_image": meta.get("cover_image", ""),
167 "body_html": markdown(body),
168 "body_html_pdf": markdown_pdf(body),
169 "read_time": max(1, math.ceil(len(body.split()) / 200)),
170 }
171 posts.append(post)
172
173 posts.sort(key=lambda p: p["date"], reverse=True)
174 return posts
175
176
177POSTS = load_posts()
178POSTS_BY_SLUG = {p["slug"]: p for p in POSTS}
179
180
181@app.before_request
182def reload_posts_in_debug():
183 global POSTS, POSTS_BY_SLUG
184 if app.debug:
185 POSTS = load_posts()
186 POSTS_BY_SLUG = {p["slug"]: p for p in POSTS}
187
188
189# -- Helpers -------------------------------------------------------------------
190
191
192def get_published_posts():
193 """Return posts where publish_date <= today."""
194 today = date.today().isoformat()
195 return [p for p in POSTS if p["publish_date"] <= today]
196
197
198def get_tags(posts):
199 """Return sorted list of unique tags with counts and URLs."""
200 counts = {}
201 for post in posts:
202 for tag in post["tags"]:
203 counts[tag] = counts.get(tag, 0) + 1
204 return sorted(
205 [{"name": t, "slug": t, "count": c, "url": url_for("blog_tag", tag=t)} for t, c in counts.items()],
206 key=lambda t: t["name"],
207 )
208
209
210def get_years(posts):
211 """Return sorted list of unique years."""
212 return sorted(set(p["date"][:4] for p in posts if p["date"]), reverse=True)
213
214
215def get_related(post, posts, count=3):
216 """Find posts with the most overlapping tags."""
217 if not post["tags"]:
218 return posts[:count]
219 scored = []
220 for p in posts:
221 if p["slug"] == post["slug"]:
222 continue
223 overlap = len(set(post["tags"]) & set(p["tags"]))
224 if overlap > 0:
225 scored.append((overlap, p))
226 scored.sort(key=lambda x: x[0], reverse=True)
227 related = [p for _, p in scored[:count]]
228 if len(related) < count:
229 related_slugs = {p["slug"] for p in related}
230 for p in posts:
231 if p["slug"] != post["slug"] and p["slug"] not in related_slugs:
232 related.append(p)
233 if len(related) >= count:
234 break
235 return related
236
237
238# -- Context processor ---------------------------------------------------------
239
240
241@app.errorhandler(404)
242def page_not_found(e):
243 return render_template("404.html", page={"title": "404", "description": "Page not found"}), 404
244
245
246@app.context_processor
247def inject_globals():
248 posts = get_published_posts()
249 return {
250 "nav_items": get_tags(posts),
251 "now": datetime.now(),
252 "debug": app.debug,
253 }
254
255
256# -- Routes --------------------------------------------------------------------
257
258
259@app.route("/")
260def index():
261 posts = get_published_posts()
262 latest_post = posts[0] if posts else None
263 rest = [p for p in posts if p != latest_post]
264 random_posts = random.sample(rest, min(3, len(rest)))
265 return render_template(
266 "home.html",
267 page={"title": "Isaac Bythewood's Blog", "slug": "home", "description": "Writing about webdev, infrastructure, security, and tooling by Isaac Bythewood, a Senior Solutions Architect in Elkin, NC."},
268 latest_post=latest_post,
269 random_blog_posts=random_posts,
270 )
271
272
273@app.route("/blog/")
274def blog_index():
275 posts = get_published_posts()
276 return render_template(
277 "blog_index.html",
278 page={"title": "Blog", "slug": "blog", "description": "Posts on webdev, coding, security, and sysadmin by Isaac Bythewood."},
279 blog_posts=posts,
280 tags=get_tags(posts),
281 years=get_years(posts),
282 breadcrumbs=[{"title": "Home", "url": url_for("index")}],
283 )
284
285
286@app.route("/posts/<slug>/")
287def blog_post(slug):
288 post = POSTS_BY_SLUG.get(slug)
289 if not post or post["publish_date"] > date.today().isoformat():
290 abort(404)
291 posts = get_published_posts()
292 return render_template(
293 "blog_post.html",
294 page=post,
295 post=post,
296 related_posts=get_related(post, posts),
297 breadcrumbs=[
298 {"title": "Home", "url": url_for("index")},
299 {"title": "Blog", "url": url_for("blog_index")},
300 ],
301 )
302
303
304def pdf_url_fetcher(url):
305 path = unquote(urlparse(url).path)
306 local_roots = {
307 "/content/images/": os.path.join(CONTENT_DIR, "images"),
308 "/static/": app.static_folder,
309 }
310 for prefix, root in local_roots.items():
311 if path.startswith(prefix):
312 file_path = os.path.join(root, path[len(prefix):])
313 if os.path.isfile(file_path):
314 return {"file_obj": open(file_path, "rb")}
315 return default_url_fetcher(url)
316
317
318@app.route("/posts/<slug>/pdf/")
319def blog_post_pdf(slug):
320 post = POSTS_BY_SLUG.get(slug)
321 if not post or post["publish_date"] > date.today().isoformat():
322 abort(404)
323 html_content = render_template("blog_post_pdf.html", post=post)
324 pdf = HTML(
325 string=html_content,
326 base_url=request.url_root,
327 url_fetcher=pdf_url_fetcher,
328 ).write_pdf()
329 return Response(
330 pdf,
331 mimetype="application/pdf",
332 headers={"Content-Disposition": f'filename="{post["slug"]}.pdf"'},
333 )
334
335
336@app.route("/posts/<slug>/md/")
337def blog_post_md(slug):
338 post = POSTS_BY_SLUG.get(slug)
339 if not post or post["publish_date"] > date.today().isoformat():
340 abort(404)
341 filepath = os.path.join(CONTENT_DIR, "posts", post["filename"])
342 with open(filepath) as f:
343 content = f.read()
344 return Response(
345 content,
346 mimetype="text/markdown",
347 headers={"Content-Disposition": f'filename="{post["slug"]}.md"'},
348 )
349
350
351@app.route("/blog/<slug>/")
352def blog_post_redirect(slug):
353 return redirect(url_for("blog_post", slug=slug), code=301)
354
355
356@app.route("/blog/<slug>/pdf/")
357def blog_post_pdf_redirect(slug):
358 return redirect(url_for("blog_post_pdf", slug=slug), code=301)
359
360
361@app.route("/blog/<slug>/md/")
362def blog_post_md_redirect(slug):
363 return redirect(url_for("blog_post_md", slug=slug), code=301)
364
365
366@app.route("/blog/tag/<tag>/")
367def blog_tag(tag):
368 posts = get_published_posts()
369 filtered = [p for p in posts if tag in p["tags"]]
370 if not filtered:
371 abort(404)
372 extra_posts = None
373 if len(filtered) < 5:
374 extra_posts = [p for p in posts if tag not in p["tags"]][:4]
375 return render_template(
376 "blog_index.html",
377 page={"title": f"Tag: {tag}", "slug": f"tag-{tag}", "description": f"Posts tagged {tag}"},
378 blog_posts=filtered,
379 extra_posts=extra_posts,
380 active_tag={"name": tag, "slug": tag},
381 tags=get_tags(posts),
382 years=get_years(posts),
383 breadcrumbs=[
384 {"title": "Home", "url": url_for("index")},
385 {"title": "Blog", "url": url_for("blog_index")},
386 ],
387 )
388
389
390@app.route("/blog/year/<int:year>/")
391def blog_year(year):
392 posts = get_published_posts()
393 filtered = [p for p in posts if p["date"].startswith(str(year))]
394 if not filtered:
395 abort(404)
396 extra_posts = None
397 if len(filtered) < 5:
398 extra_posts = [p for p in posts if not p["date"].startswith(str(year))][:4]
399 return render_template(
400 "blog_index.html",
401 page={"title": f"Year: {year}", "slug": f"year-{year}", "description": f"Posts from {year}"},
402 blog_posts=filtered,
403 extra_posts=extra_posts,
404 active_year=str(year),
405 tags=get_tags(posts),
406 years=get_years(posts),
407 breadcrumbs=[
408 {"title": "Home", "url": url_for("index")},
409 {"title": "Blog", "url": url_for("blog_index")},
410 ],
411 )
412
413
414@app.route("/search/")
415def search():
416 q = request.args.get("q", "")
417 posts = get_published_posts()
418 results = []
419 random_posts = None
420 if q:
421 ql = q.lower()
422 for p in posts:
423 if (
424 ql in p["title"].lower()
425 or ql in p["description"].lower()
426 or any(ql in t.lower() for t in p["tags"])
427 ):
428 results.append(p)
429 else:
430 random_posts = random.sample(posts, min(6, len(posts)))
431 return render_template(
432 "search.html",
433 page={"title": "Search", "slug": "search", "description": "Search posts on webdev, coding, security, and sysadmin."},
434 results=results,
435 random_posts=random_posts,
436 q=q,
437 breadcrumbs=[{"title": "Home", "url": url_for("index")}],
438 )
439
440
441@app.route("/search/live/")
442def search_live():
443 q = request.args.get("q", "")
444 posts = get_published_posts()
445 results = []
446 if q:
447 ql = q.lower()
448 for p in posts:
449 if (
450 ql in p["title"].lower()
451 or ql in p["description"].lower()
452 or any(ql in t.lower() for t in p["tags"])
453 ):
454 results.append(
455 {
456 "title": p["title"],
457 "description": p["description"],
458 "url": url_for("blog_post", slug=p["slug"]),
459 }
460 )
461 if len(results) >= 5:
462 break
463 return jsonify(results)
464
465
466@app.route("/content/images/<path:filename>")
467def content_images(filename):
468 return send_from_directory(os.path.join(CONTENT_DIR, "images"), filename)
469
470
471@app.route("/og/<slug>.svg")
472def og_image(slug):
473 post = POSTS_BY_SLUG.get(slug)
474 if post:
475 title = post["title"]
476 tags = post["tags"]
477 else:
478 title = "Isaac Bythewood's Blog"
479 tags = []
480 # Wrap title into lines (~35 chars each)
481 words = title.split()
482 lines = []
483 current = ""
484 for word in words:
485 if current and len(current) + len(word) + 1 > 35:
486 lines.append(current)
487 current = word
488 else:
489 current = f"{current} {word}" if current else word
490 if current:
491 lines.append(current)
492 return (
493 render_template(
494 "og.svg",
495 title_lines=lines[:3],
496 tags=tags,
497 ),
498 200,
499 {"Content-Type": "image/svg+xml"},
500 )
501
502
503@app.route("/favicon.ico")
504def favicon():
505 return render_template("favicon.svg"), 200, {"Content-Type": "image/svg+xml"}
506
507
508@app.route("/robots.txt")
509def robots():
510 return render_template("robots.txt"), 200, {"Content-Type": "text/plain"}
511
512
513@app.route("/sitemap.xml")
514def sitemap():
515 posts = get_published_posts()
516 tags = get_tags(posts)
517 years = get_years(posts)
518 # Compute lastmod for tag and year pages
519 tag_lastmod = {}
520 year_lastmod = {}
521 for p in posts:
522 for t in p["tags"]:
523 if t not in tag_lastmod or p["date"] > tag_lastmod[t]:
524 tag_lastmod[t] = p["date"]
525 y = p["date"][:4]
526 if y not in year_lastmod or p["date"] > year_lastmod[y]:
527 year_lastmod[y] = p["date"]
528 return (
529 render_template(
530 "sitemap.xml",
531 posts=posts,
532 tags=tags,
533 years=years,
534 tag_lastmod=tag_lastmod,
535 year_lastmod=year_lastmod,
536 ),
537 200,
538 {"Content-Type": "application/xml"},
539 )