The standard article OG image I generate for aiappdex.com is 1200×630 — the landscape aspect ratio that works well on Twitter, Discord, and most link-preview boxes. That system uses Playwright and zero image API calls, which I described earlier.
Bluesky is different. When a post links to an article that has a summary_image URL in its <meta> tags, Bluesky renders that image in portrait format — and the native crop target is 1080×1350, which is the ratio of a phone screen. A landscape image rendered at that scale ends up letterboxed and small. I wanted something that looked intentional at 1080×1350, so I built a second image pipeline.
The result: generate-summary.py, a Python script that reads a summary_data block from each article's frontmatter, renders one of three visual layouts via an inline HTML template, and screenshots it with Playwright Chromium at the exact pixel dimensions. No external API, no third-party image service.
Why a separate YAML schema instead of using the title alone
My first instinct was to generate the summary image from the article title: large text, dark background, brand mark, done. I already do something like that for cover images, so the code exists.
The problem is that article titles are often too long for a 1080×1350 canvas at a readable font size. "How I built a shared Claude Haiku client with system-prompt caching for batch ETL" is 84 characters. At 76px bold, that overflows. And even when it fits, a wall of title text doesn't communicate structure — it's just a slightly bigger version of the link card's plain text.
So I designed a summary_data YAML block that each article can opt into. The schema has five keys:
summary_data:
title_html: "YAML → <accent>Bluesky card</accent>\n1080×1350 PNG, zero API cost"
cards:
- { icon: "🤖", domain: "aiappdex.com", desc: "AI model directory", stat: "LIVE" }
- { icon: "🎮", domain: "findindiegame.com", desc: "Indie game search", stat: "LIVE" }
- { icon: "🔓", domain: "ossfind.com", desc: "OSS alternatives", stat: "LIVE" }
pipeline:
- "ETL fetch"
- "Claude Haiku gen"
- "Cache to Turso"
- "Build & deploy"
- "IndexNow ping"
stats:
- { num: "$25", label: "monthly cost" }
- { num: "3", label: "sites" }
- { num: "880", label: "pages daily" }
- { num: "6mo", label: "horizon" }
tagline: "An honest 6-month indie experiment"
title_html is the headline. It supports \n for line breaks and <accent>...</accent> tags to highlight specific words in a contrasting colour. All other keys are for the visual zone below the title: cards for a three-column comparison grid, pipeline for a sequential step flow, and stats for a four-number data row. Only one of those three should appear in any given article — the renderer handles whichever key is present and renders nothing for the others.
tagline is the footer line, always shown if present. It falls back to "An honest indie experiment" if absent.
The accent tag and why XSS-paranoia matters even in local tools
The title_html field is rendered server-side into an HTML string, which then runs in a headless browser. In theory, this is a local tool with no user input, so SQL injection isn't the threat model. But I've built enough internal tools that "local only" stops being true quickly — and the real reason to do this right is code that future-me can audit easily.
The approach: everything in title_html gets html.escape() by default. The only exception is <accent>...</accent> tags, which the renderer finds with a narrow regex, escapes their inner text separately, and re-injects as <span class="accent">.
def render_title_html(raw: str) -> str:
parts: list[str] = []
cursor = 0
for m in re.finditer(r"<accent>(.+?)</accent>", raw, re.DOTALL):
parts.append(escape(raw[cursor : m.start()]).replace("\n", "<br/>"))
parts.append(
f'<span class="accent">{escape(m.group(1)).replace(chr(10), "<br/>")}</span>'
)
cursor = m.end()
parts.append(escape(raw[cursor:]).replace("\n", "<br/>"))
return "".join(parts)
The \n-to-<br/> replacement happens after escaping, not before, so there's no way to inject markup through newlines. The outer text never runs through the browser as raw HTML. If someone puts <script> in their title_html, it renders as literal <script> on screen.
The three visual modes
The visual zone below the title picks its layout from whichever key appears in summary_data:
Cards — a three-column grid for "three things compared" framing. Each card has an emoji icon, a short domain or label, a 50-80 character description, and an optional UPPERCASE stat tag. I use this for articles that describe all three directory sites, or for comparison articles (tool A vs B vs C).
Pipeline — a five-step horizontal flow with numbered circles and arrows between them. Good for articles that describe sequential processes. The step labels support \n for two-line labels when the text is long.
Stats — a four-number row for articles with memorable metrics. The number gets rendered large in #FCD34D (amber) and the label in smaller uppercase grey. I use this for articles with cost, duration, or count data that's worth highlighting.
If none of these three keys appear, the script generates nothing for that article and the article's Bluesky post falls back to the standard landscape cover_image. The pre-post QC gate doesn't care which image field is used — it only cares that some image is present before the post goes out.
Playwright rendering and the frontmatter backfill
The script reuses a single Playwright browser context across all articles in the batch:
with sync_playwright() as p:
browser = p.chromium.launch()
context = browser.new_context(viewport={"width": 1080, "height": 1350})
page = context.new_page()
for path in targets:
meta, _ = parse_frontmatter(path.read_text())
summary = meta.get("summary_data")
if not isinstance(summary, dict):
continue
html = build_html(summary)
page.set_content(html, wait_until="networkidle")
page.screenshot(
path=str(out_path),
clip={"x": 0, "y": 0, "width": 1080, "height": 1350},
)
wait_until="networkidle" matters here. The HTML template loads Inter from Google Fonts. If the screenshot fires before the CSS is applied, the fallback system font renders instead and the result looks wrong. networkidle blocks until the font CDN request completes.
The clip parameter is important too. The context viewport is set to 1080×1350, but page.screenshot() without a clip argument would capture whatever the page actually renders to — which can be shorter if the content doesn't fill the full height. The explicit clip forces exactly the declared dimensions regardless of content height.
After generating the PNG, the script calls update_summary_image_field(), which re-reads the article file and adds a summary_image: line to its frontmatter if it isn't already there:
summary_url = f"{HOST}/og/summary/{slug_base}.png"
new_content = re.sub(
r"^(---\n)([\s\S]*?)(\n---\n)",
lambda m: m.group(1) + m.group(2) + f"\nsummary_image: {summary_url}" + m.group(3),
content,
count=1,
)
This means I never have to manually add the summary_image: URL to an article I wrote. I add summary_data, run the script, and the field appears. The downstream Bluesky queue refill reads that field when it constructs a post for the article.
The output path is apps/ai-tools/public/og/summary/<slug>.png, which Astro copies to /og/summary/<slug>.png during the static build. The same PNG is then served from aiappdex.com — the same domain that hosts all three sites' OG images, rather than spreading them across three different domains.
The CI pipeline that runs generate-og.py runs generate-summary.py in the same step, immediately after. Both scripts reuse the same Playwright installation, so there's no double browser-download cost in CI.
Choosing the visual mode per article
My decision tree for which summary_data to write:
- Pipeline: the article describes a sequential process with clear stages. Most technical "how I built X" articles fall here.
- Cards: the article compares or describes exactly three things. The three-site overview article, model comparison articles, tool roundup articles.
- Stats: the article has four numbers worth remembering. Cost, page count, API call count, duration. If fewer than four numbers, I either pad with something defensible or skip stats in favour of pipeline.
-
Omit: the article is a lightweight, recap, or curated list. The frontmatter doesn't have a clean structured story to tell. The Bluesky URL card uses
cover_imageinstead.
The content quality gate doesn't validate summary_data — that's outside its scope. But the generator script itself validates silently: a malformed YAML block causes meta.get("summary_data") to return a non-dict, and the article is skipped without error. I've been thinking about adding a dry-run validation step there.
What I'd do differently
Embed Inter as Base64 instead of fetching from Google Fonts. The wait_until="networkidle" approach works, but it depends on outbound internet access from the CI runner. In a cold environment or a GitHub Actions runner with network restrictions, the font fetch can fail silently — Playwright renders with the fallback font but doesn't raise an exception. I should detect this, either by checking that the font loaded successfully or by embedding the subset as Base64 in the HTML. The YouTube slide renderer uses Pillow with a local .ttf font for exactly this reason.
A dead reference at the top. The script defines TEMPLATE_PATH = ROOT / "scripts/summary_template.html" but never uses it — the actual template is HTML_TEMPLATE, an inline constant in the same file. I initially intended to read from the file, then inlined it for portability, and forgot to remove the reference. It's harmless but I notice it every time I open the file.
Async Playwright for batch performance. The current script uses sync_playwright and processes articles sequentially. On a full regeneration pass over 70+ articles, that's noticeable — maybe 3-4 minutes in CI. The Playwright Python library has an async version; batching 10 concurrent page.set_content calls would cut that significantly. I haven't bothered because the script only runs in the og-image CI step, not on the critical path of any content publish.
FAQ
Why not just use an image generation API?
For 70 articles, an API at even $0.02/image would cost $1.40 per full regeneration pass. In CI that runs daily, that's $42/month. Playwright Chromium is already installed because I use it for OG images and for Bluesky image upload race detection. Zero marginal cost.
What if I want to update the card design across all articles?
Change the HTML_TEMPLATE constant and rerun the script against all articles. The whole batch regenerates in a few minutes. Since the slug-based filenames are stable, the CDN-cached URLs stay the same.
Can I use more than one visual mode per article?
The renderer will display all three that are present in summary_data. But the canvas is 1350px tall and three modes together push the tagline below the fold. In practice I pick one. The template was designed for exactly that: cards fills the middle section, pipeline fills it, stats fills it. Two modes at once makes both feel cramped.
Related reading
- What I learned generating OG images for articles with Playwright and zero API cost
- How I built a YouTube slide renderer in Python — eight kinds, no browser
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (1)
I really like the decision to model the card as structured frontmatter rather than deriving everything from the article title. That turns the image into a render target instead of a manually designed asset, which makes future outputs (LinkedIn, PDFs, slides, etc.) much easier to generate from the same source. One thing I’d consider is treating summary_data as part of the build contract rather than an optional enhancement. Right now malformed YAML silently skips image generation. A schema validation step (for example with JSON Schema or Pydantic) that fails CI would make missing or invalid summary cards much easier to catch. I’d also avoid depending on external font loading in CI. Embedding a local WOFF2 subset or inlining the font would make the rendering deterministic and remove the need to rely on networkidle as a proxy for font readiness.
The overall approach feels very scalable because the structured data—not the HTML template—is becoming the real source of truth.