repos

blog.bythewood.me-flask

mirror archived upstream

Markdown blog on Flask with Vite-built assets and WeasyPrint PDF export, rewritten from the Wagtail build that preceded it.

blogdockerflaskmarkdownpythonself-hostedvite

15.4 KB · 540 lines · Python Raw History
  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    )