A Lesson from a Zola Patch

A Lesson from a Zola Patch

· 3,117 words · 14 minutes reading time

I thought I had found a small bug, and more importantly, a fix for it.

What followed taught me something about contributing to open-source projects that I had not learned before. Understanding the code is only part of the problem. Sometimes you also need to understand how the maintainer thinks about the project, where its boundaries are, and which problems belong together from their perspective.

In this case, a few lines of Rust led me through several years of discussions about math rendering in Zola.

I had essentially finished the implementation before asking whether I should submit it as a pull request. In the end, I decided not to submit it.

This is the writeup of how I got there.

It started with some broken TeX

I maintain Zolarwind, a community theme for the Zola static site generator that I also use for this site. Among other things, Zolarwind supports mathematical notation using KaTeX. The rendering happens client-side: Zola generates the site, the browser loads KaTeX, and KaTeX turns the TeX expressions into rendered mathematics.

While developing Zolarwind some time ago, I noticed that this didn't always work. Some expressions arrived at KaTeX in a different form from the one I had written in the Markdown source.

The reason is that Markdown and TeX syntax overlap in unfortunate places. Characters that are perfectly ordinary inside a mathematical expression can have a meaning to a Markdown parser. So before KaTeX ever got to see an expression, Zola's Markdown processing could already have modified it.

I didn't know the exact cause at the time. I solved the problem pragmatically by writing a katex shortcode. Rather than bypassing Markdown processing, the shortcode encoded the Markdown-sensitive characters as HTML character references so the parser would leave them untouched. The browser decoded them again before KaTeX read the DOM. It wasn't particularly elegant, but it worked.

Years later I came across a Zola support thread where someone was struggling with essentially the same problem. I started looking into it again, this time more thoroughly.

The renderer wasn't the problem

I already knew from the original Zolarwind workaround that KaTeX itself wasn't the problem. The TeX was being changed before KaTeX ever saw it.

What I hadn't understood at the time was exactly where that happened. When I revisited the issue, the relevant component turned out to be pulldown-cmark, the Markdown parser used by Zola. While looking through its capabilities, I discovered something that I hadn't known when I originally built the workaround for Zolarwind: pulldown-cmark has explicit support for recognizing math.

There is an ENABLE_MATH option that makes the parser recognize mathematical regions instead of treating their contents as ordinary Markdown. The component responsible for the problem therefore already provided a mechanism that could prevent it, but Zola simply wasn't enabling that mechanism.

This looked like a pleasantly boring bug to fix.

I wanted to see whether enabling the option would actually solve the problem, so I forked Zola and tried it. It worked. From there, I put the option behind a configuration setting, wrote tests, and updated the documentation. During that work I also discovered an improvement to my first implementation. Initially I emitted the recognized math as Event::Html. While reviewing the implementation with some help from an AI, I realized that Event::Text was a better fit because it also handled math correctly in places such as heading IDs, the table of contents, and image alternative text.

So I changed the implementation, expanded the tests, and adjusted the documentation. At that point I considered the change finished. The resulting commit touched nine files and added roughly 90 lines, including tests and documentation.

All that remained, I thought, was opening the pull request. So I read Zola's contribution guidelines.

First, discuss the feature

For changes like mine, Zola asks contributors to discuss the feature before submitting an implementation. That seemed reasonable, so I went looking for the appropriate process.

I had submitted very few open-source pull requests before this one. Those had been straightforward technical fixes where the process was essentially to identify a problem, implement the correction, and then discuss technical details during review. I approached this change with much the same expectation.

The discussion template asked me to describe prior art, so I started researching what had previously happened around math support in Zola. Only then did I realize that essentially the same underlying parser change had already been proposed, and I had walked into a discussion with a surprisingly long history.

Math support had been discussed for years. There had been conversations about MathJax and KaTeX, attempts at server-side rendering, proposals involving MathML and, later, Typst. There were also existing pull requests, notably #2708 and #2791. Some of these efforts had been around for years without producing a merged solution.

While reading through them, I noticed that they combined two questions which, from my perspective, did not have to be answered together.

The first question is what happens while Markdown is parsed. If I write TeX in my Markdown source, can it make its way through the parser unmangled?

The second question only arises after that has happened: who should render it?

That renderer might be KaTeX running in the browser, MathJax, KaTeX running during the Zola build, Typst, or something that hasn't been proposed yet. Those are all interesting possibilities, but none of them changes what the Markdown parser needs to do first.

That observation shaped the RFC I eventually posted.

Parsing is not rendering

I deliberately kept my proposal narrow. I wanted Zola to enable pulldown-cmark's math parsing and preserve the recognized contents. I didn't want the proposal to introduce a renderer or make a decision about whether rendering should happen in Zola or in the browser.

From my perspective, the relationship was straightforward:

A math rendering pipeline for TeX A math rendering pipeline for TeX

Every renderer needs the mathematical source to survive the Markdown parser. Making sure that happens doesn't commit Zola to any particular renderer.

This distinction seemed especially relevant because people were already rendering math in Zola. Community themes could and did integrate KaTeX or similar tools. Those themes weren't necessarily waiting for Zola to acquire its own rendering engine. Their immediate problem was that the TeX they wanted to render could be modified before their renderer received it.

After reading the previous proposals, I actually thought this separation might be the reason my proposal could succeed where the larger attempts had stalled. Rather than trying to solve "math support in Zola", I was addressing only the parser behavior underneath it.

I expected that the remaining discussion would be about implementation details: the configuration interface, my event handling, and maybe whether some additional tests were needed. But that wasn't what happened.

Then Typst entered the conversation

Keats, Zola's maintainer, responded to my draft RFC by bringing up Typst and server-side rendering.

I was surprised because I had tried to exclude exactly that question from the scope of the proposal. At first I genuinely thought that I had failed to explain the boundary clearly enough.

So I tried again. My point was that Typst might very well be a good answer to the rendering question, but that it was answering a question this proposal did not ask. Zola could decide later how, or even whether, it wanted to render mathematics itself. The parser change didn't prevent any of those choices.

Keats's next response was important for me because it removed the possibility that this was simply a misunderstanding. He acknowledged the distinction and nevertheless said that he wanted the rendering part to be included in the RFC because it was something many people wanted.

At that point we weren't misunderstanding each other's technical arguments anymore. We simply disagreed about where to draw the boundary around this change.

Back to the larger problem

By then I had read much more of the history than I had when I started, and I recognized the pattern from the earlier attempts.

That wasn't because I considered the rendering discussion pointless. There are good reasons to want first-class math rendering in a static site generator, and there are plenty of interesting technical questions involved.

The problem for me was that those questions were exactly what had made the math discussion so large. Once Zola itself becomes responsible for rendering mathematics, decisions have to be made about the renderer, configuration, output representation, caching, dependencies, binary size, supported platforms, packages, and theme integration. The existing work around Typst and KaTeX demonstrated how quickly the problem could grow.

Those are legitimate design questions for Zola. They just weren't questions I had set out to solve.

My intention had been to provide something useful now without constraining the answers to any of them later. When the discussion moved back toward the larger rendering problem, I had to ask myself whether I could add enough value there to justify the time it might require, especially given that variations of the same discussion had already been running for years.

I didn't think I could. So I stopped. I published the implementation in my Zola fork and preserved the patch with this article. I left the draft RFC in the forum and never opened the pull request I had already prepared.

I still disagree with that decision

It would be easy to turn this into a story where I had the obvious little fix and an unreasonable maintainer prevented it from being merged. I don't think that would be fair, and it would also miss what I actually learned from the experience.

Keats decides what Zola is going to be. A new configuration option isn't merely a few lines in a diff. Once released, it becomes part of the interface users and theme developers depend on. Its name and semantics can constrain later design choices, and somebody has to maintain it long after the person who originally proposed it may have moved on.

I hadn't really thought about my change from that perspective before, though I still disagree with the decision in this particular case.

I also wasn't particularly attached to the configuration interface I had proposed. In fact, I expected its name and location to be discussed, and probably changed, once the pull request was opened. If calling the option math implied too much, I thought, it could have some absurdly literal name describing exactly what pulldown-cmark was doing. The joke had a serious point: I cared about making the parser behavior available, not about defining the interface for Zola's eventual math support.

The parsing problem has existed for years, and during that time users and theme developers who want to provide math rendering have had to work around it themselves. pulldown-cmark now provides a mechanism for recognizing these regions, and enabling it doesn't determine whether Zola eventually chooses KaTeX, Typst, or some other solution for first-class rendering.

That's where I still disagree with Keats. In my view, users shouldn't have to wait for agreement on Zola's eventual math-rendering architecture before this underlying problem can be fixed.

Why didn't the obvious separation work?

Afterwards I became more interested in this question than in the patch itself.

I had formed the impression that Keats was fairly strict about scope creep, which initially made the response to my proposal seem particularly strange. I was trying to reduce the scope, while he was asking me to include a much larger problem in the RFC.

Looking through other Zola discussions made me realize that “Keats fights scope creep” is too crude a description of how he maintains the project.

There are examples where he has rejected functionality because he considered it too niche, better handled outside Zola, incompatible with the project's single-binary philosophy, or simply not worth the additional support surface. There are also examples where a narrow proposal was unattractive precisely because it didn't address enough of what he considered the underlying problem. In other cases he has explicitly separated concerns and postponed one part so that another could proceed independently.

What I took from this is that the size of a change wasn't necessarily the important part. Keats seems to have fairly strong ideas about what the right shape of a Zola feature should be.

I had been thinking mostly about the size and technical independence of my patch. Keats was apparently evaluating it in the context of what “math support” should eventually mean for Zola. From that perspective, the future rendering interface could influence what the parser-facing configuration ought to look like today.

I still think the two should have been separated in this case. But I can now see why saying “this patch is small and doesn't prevent anything later” wasn't enough to settle the question.

I hadn't expected that when I started.

I had been debugging only half the system

As developers, we're used to constructing mental models of systems we don't control. When something behaves unexpectedly, we investigate until we understand which component owns which behavior, what state flows where, and what assumptions the APIs make.

That's exactly what I had done with Zola and pulldown-cmark. I traced the problem until I understood why my TeX was being modified and found the mechanism that could prevent it.

What I hadn't investigated was the project around that code.

An established open-source project accumulates decisions about what belongs in core, what themes should solve, which abstractions should become public, which dependencies are acceptable, and which future directions current APIs should leave open. Some of this is documented, but much of it only becomes apparent after reading old issues, RFCs, pull requests, and the maintainer's decisions. And even then, the picture will be incomplete. As Steve Francia points out in The Maintainer's Dilemma, some of the context needed to evaluate a contribution may never have been written down at all and exists only in the maintainer's head.

Before this experience, I wouldn't have considered looking into any of that for a change like mine. Why would I? I thought I was fixing a bug.

In retrospect, that was probably the naive part. I couldn't have reconstructed everything Keats knew or thought about the project, but I could have built a much better picture of how Zola had approached this particular problem before.

Had I done that earlier, I might have noticed how much of Zola's math history was really a discussion about what math support in Zola should eventually look like. I might also have noticed that Keats had already looked at parser configuration through the lens of future rendering in earlier pull requests.

That wouldn't have changed my technical opinion. But it might have changed what I did with the solution.

Instead, I went into the RFC assuming that because the technical concerns could be separated, the project decisions surrounding them would naturally be separated as well.

A successful patch and a successful contribution are different things

This experience also changed what “finished” means to me in an open-source context.

From the technical side, the work was done. I had reproduced the problem, found its cause, implemented a solution, improved the implementation after reviewing some edge cases, written tests and documentation, and explained the solution publicly so that someone else could use what I had learned.

Previously I would have regarded getting that work upstream as the natural final stage of the same process. Now I see it somewhat differently.

Getting a change upstream can require understanding constraints and goals that weren't relevant to solving the original technical problem. It can involve public API design, backwards compatibility, future plans, and project philosophy. It can also require convincing the maintainer that the problem should be framed the way you've framed it.

None of that is unreasonable. In fact, a maintainer probably should be thinking about those things.

I just hadn't understood how separate that work could be from writing the patch.

And because my time is limited, that distinction matters to me.

What I'll do differently next time

I'll continue solving problems I encounter, and when the solution seems useful beyond my own project, I'll continue sharing it. I'll explain what I found, publish the relevant code, and try to leave enough information that somebody else can reproduce or build on the solution.

What I won't automatically assume anymore is that getting the change upstream is simply the next step.

Before deciding to do that, I'll spend more time looking at the project rather than only at its contribution guidelines. I'll look at its history, previous attempts to solve related problems, and the decisions its maintainer has made in similar situations. In particular, I'll try to understand whether we agree about the scope of the problem before I decide how much time I'm prepared to invest.

If a technical fix turns into a broader product-design discussion, I can then decide whether I actually want to take part in it. In this case, I don't.

Someone else may. I published the implementation along with the tests, documentation, and reasoning behind it. That at least leaves something concrete for anyone who wants to continue the discussion.

The patch was still useful

It may seem strange to spend this much time thinking about a change that never even became a pull request, but I don't consider the work wasted.

I revisited the problem because someone needed help with TeX that wasn't surviving Zola's Markdown processing. I now understand exactly why that happens, I know how pulldown-cmark can address the root cause, and I have an implementation demonstrating it. Other people encountering the same problem can use that information.

Before this experience, I probably would have regarded the fact that the change didn't make it into Zola as unfinished work. Not anymore.

Open source allows me to modify Zola, publish the modification, and propose that the project adopt it. The maintainer can decide that the project should approach the problem differently. If that happens, I can disagree with the technical decision without having to turn that disagreement into a fight I need to win.