My LinkedIn carousels are YAML files in Git
How I replaced Canva with a YAML → Jinja2 → Browserless pipeline to generate LinkedIn carousels as vectorized PDFs.
I've been publishing LinkedIn carousels for a few weeks. Not to "build my brand", I hate that expression, but because it's a format that forces you to get to the point on technical topics I want to share: Python PEPs, tools that change the workflow, useful patterns.
The problem is the tooling.
When you start looking at how others do it, everyone talks about Canva. It's great, it's intuitive, the templates are there, just click. So you try it.
I tried Canva. The result is clean, true. But every change means clicking, realigning, checking that the typography hasn't shifted on the next slide. And most importantly: nothing is versionable. A Canva file is a black box. No git diff, no generating ten variants at once. After an hour, I already wanted to script everything.
And the time it takes is insane. You have to verify that everything looks right visually, that it's consistent, that the text doesn't overflow. The problem: we're told you need to publish at least once a week, even several times if you want visibility. At that pace with Canva, it's impossible.
The history of presentations
A LinkedIn carousel is fundamentally a slide deck. Ten slides, one message per slide, text + a visual. I'd made dozens for conferences. The solution might already be there, somewhere in my old tools.
I'd used LaTeX, then Keynote, tools that render well but are heavy, non-scriptable, and impossible to version properly.
Around 2014-2015, I discovered Remark.js, a tool that transforms Markdown into HTML slides. Exactly what I wanted: versionable text, CSS for styling, a URL to share. I gave several talks at PyCon and EuroPython with it, I also ran my Python training courses with it. Really great.
Then Covid. No more conferences. Remark.js stayed in a corner of my bookmarks.
Today, wanting to pick it back up, LinkedIn rather than a stage, but same idea, I reopened the project. Problem: the last release is v0.15.0, dated January 18, 2020. Five years without activity. I preferred to look elsewhere. I looked at Slidev and MARP without really digging in, and honestly, I wanted to write something adjusted exactly to what I wanted. Classic developer mistake.
I could have started with Markdown, like Remark.js or MARP. A text file, --- separators between slides, a front matter per section. But that meant writing a parser, managing the split on separators, distinguishing each slide's front matter from the body, and in the end, inventing a structure to say "this one is a cover", "this one is a CTA". Better to declare it explicitly.
YAML won out for three concrete reasons: native syntax highlighting in every editor, schema validation (if I expect a string for the title and pass an integer, it blows up cleanly), and extensibility via a Pydantic model. No parser to write, PyYAML does the work in one line.
YAML in, HTML out
The structure is simple: a YAML file describes a deck, and a Python script produces HTML via Jinja2.
meta:
title: "UV — the new pip"
theme: editorial
author: Stéphane Wirtel
cover:
eyebrow: "Python · Packaging · 2026"
title: "<em>uv</em><br>The new pip"
subtitle: "Installation, lock files, reproducible scripts — in 8 slides."
tags:
- "uv"
- "Python 3.13"
- "packaging"
slides:
- type: features
heading: "Why <em>uv</em>?"
items:
- icon: "⚡"
title: "10-100× faster"
desc: "Resolver written in Rust. The usual `pip install` becomes instant."
- icon: "🔒"
title: "Native lock files"
desc: "`uv.lock` — guaranteed reproducibility, no extra plugin needed."YAML because it versions cleanly in Git. Because you can lint and validate the schema. Because the separation of content and rendering is clean. No opaque binary XML, no Canva file that only the SaaS understands.
The Jinja rendering is minimal: the template iterates over slides, applies the right HTML block based on the type, injects the variables. I've been using Jinja since 2011 with Flask, it's a reflex.
{% for slide in deck.slides %}
<section class="slide slide--{{ slide.type }}">
{% if slide.type == 'features' %}
<h2>{{ slide.heading | safe }}</h2>
<ul class="features">
{% for item in slide.items %}
<li>
<span class="icon">{{ item.icon }}</span>
<strong>{{ item.title | safe }}</strong>
<p>{{ item.desc | mdcode }}</p>
</li>
{% endfor %}
</ul>
{% elif slide.type == 'code' %}
<h2>{{ slide.heading | safe }}</h2>
<pre>{{ slide.code | safe }}</pre>
{% endif %}
</section>
{% endfor %}One feature I use a lot: code blocks can reference external files rather than embedding the code directly in the YAML. In the deck, it looks like this:
- type: code
heading: "In practice"
blocks:
- file: "after.py · Python 3.15+"
source: samples/pep-661-sentinel-values/after.pyAnd the after.py file is a real Python file in the repo:
# ruff: noqa: F821
from copy import deepcopy
import pickle
MISSING = sentinel("MISSING")
def read(default: int | MISSING = MISSING) -> int:
if default is MISSING:
return fetch()
return default
# ✅ clear repr, typing OK, identity preserved
assert repr(MISSING) == "MISSING"
assert deepcopy(MISSING) is MISSING
assert pickle.loads(pickle.dumps(MISSING)) is MISSINGThe advantage is concrete: this file is a real Python module, not a string buried in YAML. You can run ruff format, ruff check, mypy on it, all Python toolchain tools work normally. If the code has a syntax error, the linter catches it before even building the carousel. The slides show code that is guaranteed correct and formatted.
For syntax highlighting in the code slides, I use Pygments. In the YAML, a code block can reference an external file via source: path/to/file.py. Before Jinja even touches the template, an inline_sources() pass reads the file, detects the lexer (by explicit name or by extension), and replaces the source: key with colorized HTML:
def inline_sources(deck: dict, deck_path: Path) -> None:
formatter = HtmlFormatter(nowrap=True)
for slide in deck.get("slides", []):
if slide.get("type") != "code":
continue
for blk in slide.get("blocks", []):
src = blk.pop("source", None)
lang = blk.pop("lang", None)
if not src:
continue
full = (deck_path.parent / src).resolve()
text = full.read_text(encoding="utf-8")
lexer = get_lexer_by_name(lang) if lang else get_lexer_for_filename(str(full))
blk["code"] = highlight(text, lexer, formatter).rstrip()The nowrap=True is important: Pygments spans land directly inside the template's <pre>, without a <div class="highlight"> wrapper. CSS rules in syntax.css are therefore scoped to pre .k, pre .nf, etc.
Browserless, the piece that makes it click
Having clean HTML is good. Getting a PDF from that HTML, that's where things usually get complicated.
The naive solution: capture screenshots slide by slide and assemble a PDF. It works. But the result is a rasterized PDF, and that's a problem.
Rasterizing means converting something to pixels. When you take a screenshot of a slide, the text, shapes, and colors are all converted into a grid of colored dots. The word "Python" in the title is no longer a sequence of characters, it's pixels that look like letters. Consequence: the text is no longer selectable, you can't Ctrl+F in the PDF, and if you zoom too much, it pixelates. For a 10-slide deck at 1080×1080 px, each screenshot easily weighs 500 KB to 1 MB, so 10 slides quickly adds up to 10-12 MB. LinkedIn has upload limits, and the result is visually less clean.
The obvious alternative: Chrome's "Print to PDF". Chrome knows how to produce a true vector PDF, text stays as text, CSS shapes stay as curves, fonts are embedded. But it's a manual operation, not scriptable. As soon as you want to generate ten different themes or automate the process, it's dead.
That's where Browserless comes in. A Docker image that exposes a REST API around Chrome. The /pdf endpoint does exactly what "Print to PDF" does in Chrome, but via a simple HTTP call. You send it the slide HTML, it spins up headless Chrome, renders the page, and returns a vector PDF. Text stays as text, fonts are embedded, CSS shapes are vectors. Result: 2-3 MB instead of 10-12 MB, and a clean file.
# docker-compose.yml
services:
browserless:
image: ghcr.io/browserless/chromium
restart: unless-stopped
ports:
- "3000:3000"
environment:
TOKEN: ${BROWSERLESS_TOKEN:-secret}docker compose up -dAnd on the Python side, the call looks like this:
class BrowserlessClient:
def __init__(self, url: str, token: str | None = None) -> None:
self.endpoint = f"{url.rstrip('/')}/pdf"
self.params: dict[str, str] = {"timeout": "90000"}
if token:
self.params["token"] = token
@classmethod
def from_env(cls) -> "BrowserlessClient":
url = os.environ.get("BROWSERLESS_URL")
if not url:
raise RuntimeError("BROWSERLESS_URL is not set")
return cls(url, token=os.environ.get("BROWSERLESS_TOKEN"))
@staticmethod
def _payload(html_str: str, width: str = "11.25in", height: str = "11.25in") -> dict:
return {
"html": html_str,
"options": {
"width": width,
"height": height,
"printBackground": True,
"margin": {"top": "0", "bottom": "0", "left": "0", "right": "0"},
},
"gotoOptions": {"waitUntil": "load", "timeout": 0},
"waitForTimeout": 3000,
}
def render_pdf(self, html_str: str, out: Path, width: str = "11.25in", height: str = "11.25in") -> None:
with httpx.Client(trust_env=False) as client:
r = client.post(
self.endpoint,
params=self.params,
json=self._payload(html_str, width, height),
timeout=90.0,
)
r.raise_for_status()
out.write_bytes(r.content)That's literally it. BrowserlessClient.from_env().render_pdf(html_str, out), POST the HTML, receive the PDF bytes, write to disk. The result: a deck that weighed 10-12 MB in rasterized form drops to 2-3 MB. Five times smaller, and the text is vectorized.
A few details that save time: Browserless v2 uses waitForTimeout (not waitFor as in v1). The printBackground: true is essential, without it, dark backgrounds and glow effects disappear. And waitUntil: "load" rather than networkidle0, otherwise you wait 90 seconds for Google Fonts to load.
Useful bonus: the /screenshot endpoint works with the same payload. Before launching the full PDF generation of a deck, I can quickly check that no slide overflows.
One final pass after generation: I inject metadata into the PDF. Author, title, description, keywords, date, the standard fields that PDF readers display in their properties. It's a matter of cleanliness, and it also helps if the file ends up indexed somewhere.
def inject_pdf_metadata(path: Path, meta: DeckMeta) -> None:
writer = PdfWriter(clone_from=path)
keywords = ", ".join(meta.keywords)
pdf_date: str | None = None
if meta.date is not None:
# PDF format: D:YYYYMMDDHHmmSS
pdf_date = f"D:{meta.date.strftime('%Y%m%d')}000000"
info: dict = {
"/Title": meta.title,
"/Author": meta.author,
"/Subject": meta.description,
"/Producer": f"{meta.author} — {meta.brand.url}",
"/Keywords": keywords,
}
if pdf_date:
info["/CreationDate"] = pdf_date
info["/ModDate"] = pdf_date
writer.add_metadata(info)
with open(path, "wb") as f:
writer.write(f)This information comes directly from the deck YAML frontmatter, author, title, description, keywords, date. Nothing to enter twice: what's declared in the source file ends up in the generated PDF's metadata. DeckMeta is a Pydantic model, meta.date is already a Python date object, not a string, so no conversion needed.
Themes in minutes
Once the pipeline is in place, adding a theme costs almost nothing.
I added a theme system this week. Each theme is a folder: tokens.css for the palette and fonts, syntax.css for Pygments highlighting, an optional overrides.css for layout adjustments. The deck picks its theme with a single theme: editorial line in the YAML. You can also pass it on the command line, handy for generating the same content in 16:9 for Speaker Deck without modifying the source file.
What changes everything is the marginal cost of a new theme. Concretely: I give a color/typography brief to Claude Code or a tool like Open Design, I get CSS back, I test the render in thirty seconds with task html. A few minutes total. No need for a designer's eye.
It opens up ideas: a slightly festive theme for the holiday season, an "anniversary" theme with a warmer background for a company's tenth year, a sober theme for a serious topic. Before, this kind of variation meant reopening Canva, readjusting everything by hand, and hoping nothing breaks. Now, it's a CSS folder and one line in the YAML.
Closing thoughts
What I like about this pipeline is less the technology than the principle: versioning your visual work like code. Every deck is a text file in Git. You can diff, branch, go back, generate ten variants with a script. The marginal cost of a new format (16:9, stories, handout PDF) is minimal once the chain is in place.
The building blocks are available, documented, and affordable, Browserless runs locally for free, Jinja and Pygments have been in the Python ecosystem for a long time.
About the tool itself: it's a homemade prototype, not yet published. It runs, I've been using it for a few weeks, but I'm not yet certain about all the choices, particularly around the input format. I'm thinking about moving from pure YAML to Markdown with front matter, in the style of MARP or Slidev. Before making it public, I want to make sure the project is viable, stable, and robust enough that others can use it without fighting edge cases. It'll come, but not yet.
If you're interested in what comes next, auto-generated themes, and eventually publishing the tool, leave a comment or a like. That's what tells me the topic resonates and that it's worth continuing to write about.

