My book has a linter that yells at me for hyphenating “open source.”1 It runs ~5,500 automated checks, rebuilds five formats on every git push, and fails the build if I so much as imply I still work at a job I left. For a book. That one person wrote.
I didn’t set out to do this. Most people start a writing project by firing up Word or Google Docs, and I started down that same path. I even tried some purpose-built authoring tools, but every tool felt inferior to the ones I used as a developer every day. I did “the only reasonable” thing and threw all of them out, writing the whole book on Git, Markdown, and a CI build pipeline. If you’ve read how I over-engineered my home network — twice — none of this will surprise you.
I’ve been making websites for decades, so the leap was short: the same tools that build websites could build books, no last-minute magic-trick reveal required to abracadabra a standard Word doc into a full-blown book. There were missteps,2 but in the end, I would not have written Open and Async any other way. Here’s how:
Content
Naturally, the content itself lived as Markdown files in a Git repository. After all, that’s where I spend most of my day. I used VS Code, with a handful of prose extensions (listed below). Each chapter was its own Markdown file, and a single index.yml file defined the order, making it easy to re-order chapters or add new ones.
Practically, I wrote most of this book on an iPad — Codespaces in a browser tab, a Bluetooth keyboard, often nights and weekends while away from my desk — and the Git repository kept everything in sync no matter where I opened it. I could focus on the words, and a bad idea was one git revert away from gone.
Not to mention, I had real-time feedback on my writing right in my IDE from the various prose linters, just as I would have real-time feedback on my code from ESLint or Prettier.
Testing
With content as code, the next logical step — and the point where “reasonable” quietly left the building — was to set up automated tests. Testing prose the way you test code is something I’d argued for years; this was me taking it to an absurd extreme. I did that two ways: real-time, and on push (CI).
Real-time
Locally, as I typed, I ran several VS Code extensions all giving me real-time feedback. Specifically:
- Markdownlint — Markdown syntax and formatting consistency
- Harper3 — grammar and word choice, entirely on-device
- LanguageTool — grammar, punctuation, and style4
- Vale — my own house-style rules and banned terms
- Alex — insensitive or exclusionary phrasing
- Write-good — weak prose: passive voice, weasel words, clichés
All six also ran in CI (Alex and Write-good folded into Vale there; more on that below). Together, they layered hundreds of curated style rules over a full grammar engine — all of it underlining my mistakes in real time, the way a red squiggle flags a type error.
On each push
In addition to running those open source linters in CI (some blocking), I built a custom test suite of my own: a standalone Node script of content validators, a Vitest suite, and Playwright specs. The validators are the fun part. Each is a few lines that read the Markdown and push an error with a file-and-line pointer. My favorite: I don’t work at GitHub anymore, so any sentence claiming I still do fails the build.
// My time at GitHub has to read as past tense — present-tense
// employment claims about it fail the build.
function validateGitHubTense(files) {
// "is/are ... at GitHub" — a current-employment claim
/\b(is|are)\b[^.!?\n]{1,80}?\bat GitHub\b/i,
// "works/leads/runs at GitHub" — current-employment activity
/\b(works?|leads?|runs?|manages?|directs?)\s+(at|for)\s+GitHub\b/i,
return flagLinesMatching(files, patterns);
That validator exists because I made the mistake once — which is the whole pattern. The first time an error got past me, I didn’t just fix that one sentence; I wrote a rule so I’d never have to catch it by eye again. It’s cattle, not pets for prose: don’t hand-nurse each chapter, govern the whole herd with policy. A mistake caught once becomes a check that sweeps every chapter and fails the build if it ever wanders back. That’s one of about thirty. Others I’m proud of:
validateOpenSourceHyphenation— “open source” is a noun, not a verb, and never hyphenated.validateHypotheticalHooks— formulaic AI-tell openers (“Picture this…”, “Imagine…”, “Consider a…”) at the start of a paragraph.validateSentenceStarters— three-plus sentences in a row opening with the same word.validateNoBareUrlLinkText— no link whose visible text is just the raw URL.validateCalloutBalance— the book speaks to managers and individual contributors, so a “For managers” callout has to have a “For ICs” counterpart nearby.validateCrossReferences— every[text](#anchor)cross-reference resolves to a real heading.
…plus a couple dozen more for small caps, em-dashes, doubled words, en-dash ranges, TL;DR length, and every other tic I could name.5
One of these caught me. validateHypotheticalHooks flagged the opening of a paragraph I was certain I’d written myself — and I had. But reading it back cold, it did sound ghost-authored; I’d absorbed the cadence from reading too much generated text and produced a fluent imitation of nothing. A linter can’t tell good prose from bad. What it can flag are the patterns you reach for when you’ve stopped thinking — which is exactly the thing you can’t see in your own draft. Same reason eslint earns its keep: it can’t tell good code from bad either, but it catches the autopilot mistakes your own eye skates right over.
All in all, the CI suite ran ~70 test files with 2,204 test cases and ~3,900 expect() assertions — plus another ~1,600 per-chapter structural checks from the validators above.
Audits
The linters caught mistakes, but they couldn’t tell me whether the book repeated itself or whether a chapter was any good. For that I built a second layer of tooling: audits that judge the writing, not just check it. One thing they never did, though, was write it. Every word is mine; these tools are readers of last resort — catching what I’d stopped being able to see.
Duplication detection
After reading the book over and over, I was convinced I’d repeated the same idea across chapters. I wanted proof, not a hunch — so I built three layers of duplication detection, each catching what the one before it misses:
jscpd— token-level copy-paste detection. Catches longer verbatim blocks I’d pasted between chapters, but nothing subtler.6- n-gram — tokenizes every chapter in
index.yml, strips Markdown/Pandoc syntax, builds word n-grams (phrases of n words), and flags phrases appearing in more than one chapter (plus a “most-repeated stock wording” ranking). Two modes: cross-chapter (default--n) and intra-chapter (--scope=intra --n=8) for a chapter repeating itself. - semantic — the one the other two can’t do: the same point restated in different words. An on-demand LLM audit, designed so it never feeds the whole book to a model — three passes, each over small units:
- intra — one call per chapter: “where does this chapter restate itself?”
- cross — a single call over every chapter’s TL;DR, producing a map of conceptually overlapping chapters. A whole-book scan for the cost of one call.
- arguments — extract each chapter’s load-bearing claims one call at a time, then cluster the same argument across all chapters in one final call. Catches arguments made in body prose that
crossmisses.
Was I actually repeating myself? By this final pass the two mechanical layers came up clean — but they’d earned it: jscpd and the n-gram scan had already caught the copy-paste and recycled phrasing (a doubled motif here, a reused TL;DR structure there), and I’d fixed each.
What neither could see was the subtler kind — nothing was duplicated word-for-word anymore, just the same point in different words. The semantic pass caught that, and it was right: I’d made the same argument, that moving office habits online isn’t the same as working remote-first, in five separate chapters, on top of 166 smaller self-restatements scattered across 51 chapters. I’d paraphrased myself too well for anything cheaper than an LLM to catch me. (share this quote)
My hunch was correct; I’d just needed three escalating tools to prove what re-reading my own book to blurry-eyed exhaustion couldn’t.
Content audits
Beyond duplication, a second set of tools graded the prose itself — split by how they judge. Some are probabilistic (an LLM reads the chapter and forms an opinion; run it twice and the findings can shift), and some are deterministic (rules and arithmetic — same input, same output, every time).
Probabilistic (LLM) audits
The Claims lens paid for itself early. It stopped on a sentence claiming GitHub’s monthly all-hands “dedicated roughly half of each session to live Q&A” — a number I was certain of and had wrong; it was closer to a third, and the share moved around over the years. No linter flags that: it’s clean, confident prose that happens to be false. Only a reader asking “is this actually true?” catches it, and I’d have shipped it otherwise.
Claims is one of 20 single-purpose lenses in a “prose audits” test — a per-chapter LLM auditor where each lens asks one narrow question so the model can’t hand-wave a vague “looks good.” --lens=all runs every lens; --models/--rounds add a deduplicated multi-model and self-consistency panel, so a finding has to survive more than one model (or more than one run) to count. A sampling of the rest:7
| Lens | What it catches |
|---|---|
| AI-tells | Phrasing that reads as machine-generated rather than my voice |
| Hook | Whether the opening earns its place or is throat-clearing |
| Legal | Legal or reputational risk (naming names, unverified claims) |
| Dated | Perishable references that will age badly (“recently,” “this year”) |
| Global | Idioms and cultural assumptions that trip up non-US readers |
| Dual-audience | Whether a chapter serves managers and individual contributors |
| Promise | Whether the chapter delivers on the book’s core promise |
Separately, I also had a traits analysis test, which scored each chapter on persuasion and engagement traits, catching prose that was technically clean but flat so I could give it another (human) pass.
Deterministic audits
I ran these after a round of editing, to see whether the book was improving as a reading experience: chapter- and paragraph-length distributions, whole-book reading time, a hard EPUB size budget (Kindle penalizes oversized files on delivery), a book-wide consistency checker, and a stats dashboard whose --check mode fails the build unless every “attention item” — a chapter missing a TL;DR, an unbalanced callout — sits at zero.8
Together, these turned “is the book actually getting better?” from a gut feeling into a number I could watch move between drafts.

Design
I am far from a designer, and I’d never published an ebook before — I had no idea how they worked beyond reading many of them. Two aha moments changed the way I thought about publishing:
- An ebook is a website in a trench coat (share this quote) — ebooks are just HTML and CSS, albeit a very stripped-down version.9 If you can make a website, you can make an ebook.
- A print book can be one too, with enough effort — CSS natively has powerful
@media printand@pagerules, including left and right page styling, title pages, page numbers, and more.
For the interior design, I reached for Tailwind CSS — not because it’s built for books (it isn’t), but because it’s what I already use every day. @tailwindcss/typography gave me a typographic baseline to start from instead of a blank page.
One note: I purposefully hired a human designer for the cover. For a book about being authentic, the first impression had to be authentic.10
The invisible leak
Styling every format from one stylesheet has a failure mode: CSS meant for one format bleeding into another. To keep my e-reader tweaks away from the paperback, I scoped each of them to @media not screen. But WeasyPrint — the HTML/CSS-to-PDF engine that draws the paperback, more on it below — renders the print PDF with media type print, and print is “not screen,” exactly as the spec says. So two of those e-reader rules matched anyway and quietly bled into the book: a tighter line-height on callout paragraphs, and a box-decoration-break change that dropped the repeated padding on any callout that split across a page.
Neither was visible. Nothing looked broken. But each shaved a sliver of vertical space off every callout, and the book slowly deflated from 576 printed pages to 567. I only found out because the print PDF is pinned to exactly 576 pages11 and the build went red — a page-count gate written to protect the cover spine, catching a CSS bug it was never designed for. Two formats from one stylesheet is a gift, right up until the cascade forgets which format it’s in. (share this quote) The fix re-pins the original values in a print-only block, loud with !important so the leak can’t win.
Tests
The design and layout got a test suite as well — driven by Playwright against the built HTML, so it checks what actually renders, not what I hoped the CSS did. Two categories:
Accessibility (axe-core)
The axe-core engine runs over the rendered book in both light and dark mode against WCAG 2.1 AA — no critical or serious violations, AA color contrast in either scheme, alt text on every image, logical heading order, discernible link text (no bare “click here”), and a declared lang attribute so screen readers pick the right pronunciation. It’s the test:a11y gate, enforced in CI as validate-playwright. Accessibility earned more than a subsection here — it has its own accessibility statement, and a dedicated deep dive is coming.
Visual & layout regression (Playwright)
These assert that the actual computed styles match the design intent — the details that break quietly and never show up in a diff. A representative few: the title page renders centered with a lighter-weight subtitle; the table of contents is a real nav with the doc-toc role and working links; every callout type carries its own unique left-border color and auto-generated label prefix; and small caps get real font-variant: small-caps, not just a class name.12
None of this is glamorous. It’s exactly the kind of thing that breaks silently in a Friday CSS refactor and doesn’t surface until a reader emails you.
Building
We’ve got clean Markdown, and a test suite that ensures the content is clean, but we still need to get it into a format that can be published (EPUB, Kindle, paperback in two flavors, and web). Core to that custom build pipeline was Pandoc, a universal document converter that can read Markdown and spit out just about anything else. I used Pandoc as the engine, but Pandoc alone gets you a generic document — turning that into five store-ready formats took a pile of custom tooling:
A reproducible toolchain
Pandoc is only the engine. The full build also needs WeasyPrint to draw the PDF, Ghostscript to convert it, and a pile of Noto fonts for full Unicode coverage — a gnarly stack of native dependencies to install by hand. The whole thing therefore lives in a Docker image and a dev container, pinned to an exact renderer version (a detail that matters more than you’d think — see the page-count gate below). That container is also why I could draft the book from an iPad: the heavy toolchain ran in Codespaces, not on my lap.
Pre-processing (before Pandoc sees the text)
Everything starts on a throwaway copy of the source tree, so these transforms never touch the real chapters. A couple of scripts massage that copy before Pandoc runs — stamping each build with its commit SHA, turning inline links into numbered footnotes for print (a hyperlink is useless on paper; a footnote with the full URL isn’t), and reshuffling the chapters per format so the EPUB gets shareable quote links while print and Kindle get a QR share page instead.13
CSS pipeline
One Tailwind stylesheet (src/style.css) compiles through PostCSS to dist/style.css, so screen, print, and EPUB all start from the same source of truth. Each format then takes a different slice: strip-page-rules.js derives a cut-down stylesheet for EPUB, stripping the CSS Paged-Media features (@page, target-counter(), oklch(), custom properties) that Kindle and EPUBCheck choke on.
Lua filters (the interesting part)
This is the part I didn’t expect to love. Pandoc parses everything into an abstract syntax tree (AST) and lets you rewrite that tree with small Lua scripts before it renders — so format-specific tweaks live in code, not smeared through the prose. Deleting a node, for instance, is just returning an empty table:
-- strip-comments.lua: drop editorial HTML comments so my notes-to-self
-- never ship inside the published .xhtml. Returning {} removes the node.
if is_html_comment(el.format, el.text) then
The rest are variations on that idea:
add-div-titles.lua— injects the callout labels (“TL;DR: ”, ”💡 Pro-Tip: ”, ”👔 For managers: ”) and DPUB-ARIA accessibility roles, so those labels aren’t hardcoded in every chapter and can differ per format.- Emoji, handled three ways — color emoji render fine on screen, but
strip-emoji-kindle.luaswaps them for Kindle-safe glyphs (e-ink has no emoji font) whilebody-emoji-images.luaturns them into inline Twemoji images for the print PDF. tagline-share-links.lua— appends a “share this idea” permalink after each of the book’s bumper-sticker lines in the EPUB.
The dark box
Turning emoji into images solved the missing-font problem and created a subtler one. Twemoji’s PNGs are transparent, but the RGB underneath the transparent pixels is dark slate — invisible if a renderer honors the alpha channel, an ugly charcoal box if it doesn’t. And whether a renderer honors alpha turned out to depend entirely on where the book got opened.
On Kindle, my first fix baked the callout’s background color into each PNG and dropped the alpha outright. It looked perfect on a light-mode Paperwhite and terrible on Kindle for iOS, which does honor transparency and doesn’t paint the callout tint behind the image — so every emoji got a colored rectangle instead of a dark one. I’d traded one box for another. What shipped keeps the alpha intact but rewrites the RGB of every fully transparent pixel to white: renderers that honor alpha get clean transparency; the ones that flatten it get white, which vanishes on a white page.
The print PDF needed the opposite fix. Ghostscript converts the paperback to PDF/X-1a with -dNOTRANSPARENCY — required to keep the text as selectable vectors instead of a 300-DPI bitmap — and that flag always drops the alpha. So there, transparency is the enemy: each emoji is flattened onto the exact color it sits on (the callout’s gray, or white for body text) with no alpha at all, so there’s nothing to drop and no box to reveal. The same bug needed opposite fixes — one runtime might drop the alpha, the other was guaranteed to. (share this quote) A Python script bakes both sets at build time.
It’s the most ordinary bug in the world — mine just happened to be in a book.14
Rendering & post-processing per format
With the tree transformed, each format renders down its own path — and the paperback takes the longest road. HTML and the PDF both render through Pandoc, but the PDF is drawn by WeasyPrint, an HTML/CSS-to-PDF engine — so I typeset the entire book with the same box model I’d use for a web page. The EPUB goes a different way: Pandoc embeds WOFF2 font subsets, then postprocess-epub.js repackages it to stay valid and small. And the paperback keeps going after everyone else has stopped, out to Ghostscript to become PDF/X-1a:2001 — CMYK color, an embedded USWebCoatedSWOP ICC profile, flattened transparency — the archaic print standard IngramSpark (the print-on-demand distributor) won’t live without.
That same 576-page gate earns its keep here too. check-pdf-page-count.js pins the count and fails the build the moment the paperback drifts off 576 pages — cheap insurance against a stale page count shipping as a wrongly-sized cover spine.
That’s five publishable files out of one Markdown source15 — which raises the obvious question: how do I know any of them are actually correct?
Testing the build
The prose linters keep the words honest; a larger slice of that Vitest suite keeps the build honest. A Pandoc upgrade or a stray line of CSS can silently break a format, and I wouldn’t find out until a reader’s Kindle rendered the wrong font. The tests check the machinery: that each transform does what it claims, and — the biggest cluster by far — that typography hasn’t quietly drifted across small caps, table borders, print contrast, dark mode, and the e-ink emoji fallback. Each is a test because each is a way I’ve watched a build look fine and render wrong.
But unit tests only prove my code is right, not that the output is valid — so each artifact also runs through the same validators the stores themselves use, and a rejection happens on my laptop, not on upload day. Every EPUB goes through EPUBCheck, the validator every store runs on upload, and Ace by DAISY, an accessibility audit built specifically for ebooks; the built HTML gets crawled by lychee for link rot and run through an HTML validator before malformed markup can become a malformed ebook.
None of this is something I have to remember to run. Every push to main fans out into 14 parallel CI jobs — build the CSS, then the EPUB, Kindle EPUB, print PDF, PDF/X-1a, HTML, and DOCX side by side, and validate each as its artifact comes ready. A green check means every format built and passed every gate; a red X means something’s off before I’ve even alt-tabbed away.
# build.yml — mostly independent jobs, so CI runs them in parallel
build-epub: # → dist/book.epub
build-kindle: # → Kindle-specific EPUB
build-pdf: # → 6"×9" paperback PDF
build-pdf-ingramspark: # → PDF/X-1a for IngramSpark
build-html: # → web preview
validate-epubcheck: { needs: build-epub }
validate-ace: { needs: build-epub }
validate-playwright: { needs: build-html }

And because every build stamps its commit onto the title page, the book is versioned like software. I’m on release 1.0.1 — with a tag and a changelog, not a graveyard of final_v3_revised_ACTUALLY_FINAL.docx files. A typo fix is a point release.
Publishing
Here’s the irony: after automating everything up to this point, the actual publishing is almost entirely click-ops. There’s no git push to production. Each store — Amazon’s KDP, IngramSpark for bookstores and libraries, Draft2Digital for Apple Books and Kobo — wants you to log into a web dashboard, upload the EPUB and the print PDF by hand, and re-enter the same metadata (title, description, BISAC categories, keywords, price) into a slightly different form each time. My pipeline builds a flawless artifact, and then I upload it like it’s 2009. (share this quote)
A few things kept even that part honest:
- I’m my own publisher. I formed an LLC and bought my own ISBNs — one per format — so the catalogs list me as the publisher, not a free platform ISBN with the retailer’s name on it — and none of the vendor lock-in that rides along with one, since a free ISBN only publishes through the platform that handed it out.
- Wide, not exclusive. I skipped Amazon’s KDP Select, which pays a bit more in exchange for locking the book to Amazon, so the book could be available everywhere at once.
- The metadata is versioned too. The description, categories, and keywords live in the repo — a single source of truth I can diff — and an ONIX feed is generated from it for the channels that accept one. I still paste it into web forms by hand, but at least I’m pasting from a file under version control.
Looking forward
The nice thing about a book that builds like software: the same machinery keeps paying off after launch.
- Translations. Because the content is structured Markdown, translating it is closer to localizing an app than retyping a manuscript. I’ve got a pipeline (Brazilian Portuguese, Latin American Spanish, German) that drafts a translation of the finished English book, pins every heading ID so cross-references don’t break, enforces a glossary so key terms stay consistent, and back-translates the result to check it against the original — the same trust-but-verify instinct as the prose linters, just across languages.16
- An audiobook. If I record one, turning 100,000 words into narration opens a whole new class of bug: mispronunciations — so the audiobook has its own QA suite. It transcribes the generated audio back to text and diffs that against the script, checks phonemes on the tricky words, and audits pronunciations — all wired into their own CI workflows. It’s a snapshot test, aimed at my own voice.17
- Releasing the tools. Almost none of this is specific to my book — the validators, the Lua filters, the whole pipeline. I’d like to clean them up and put them out as open source (a noun, no hyphen), so the next person doesn’t have to build it from scratch. And none of it would exist without the open source projects it already stands on — the whole book is other people’s generosity, compiled.18
Conclusion
One Markdown source, a linter with opinions, and a build that yells when I’m wrong — here’s what all that over-engineering added up to.
By the numbers
| Metric | Count |
|---|---|
| Words | ~100,000 across ~70 chapters and sections |
| Print length | 576 pages (6”×9”) |
| Published formats | 5 (plus a DOCX I don’t ship) |
| Tests | ~70 files, 2,204 cases, ~5,500 automated checks |
| CI jobs per push | 14, in parallel |
| Style rules | ~300 covering 500+ banned terms, over a 5,000-rule grammar engine |
| Commits | 5,000+ to get here |
| Books | 1 (frankly over-engineered, but fun) |
The tooling outgrew the book, too. I pointed the whole apparatus at this blog’s fifteen-year archive and caught a 2011 post that had been saying “its clear” where it meant “it’s clear” the entire time — the exact missing apostrophe my linter now flags on every keystroke. Thousands of people read it; nobody ever mentioned it. You don’t find out. You have to go looking. (share this quote)
And it wasn’t only the tooling that transferred — the habits did. The instincts I sharpened here — treat a mistake caught once as a rule that catches it forever, keep a human in the loop on anything a machine drafts, make the build the source of truth — have already found their way into how I run my other projects, this site included.19
I’m not going to tell you to write 5,500 tests for your novel. For most books, most of this is wildly disproportionate — and that’s the point. I did it because the marginal cost of one more check, one more format, one more validator was a few minutes and a little curiosity, and because doing it the developer way meant I could write from an iPad away from my desk and trust that main was always shippable. If you spend your days shipping software, the tools you already know will take you further than you’d expect. The publishing industry starts with a word processor. I started with a Git repository (share this quote) — and I’d start there again.
Out now
Open&Async
The collaborative software development playbook for remote and distributed teams. Drawing on a decade at GitHub — on Kindle, Apple Books, Kobo & more.
Buy it — $9.99What would you over-engineer, if you pointed your own tools at it?