A draft came out of my blog generation pipeline with the frontmatter intact, the title correct, the word count in range, and roughly 1,400 words of prose rendered inside a single syntax-highlighted code block. Everything after the first bash example was gray monospace on a dark background. The build passed. The exit code was 0.
One sed expression caused it, and it had been sitting in the generation script since the pipeline moved from a local model to a cloud backend. It did exactly what it was told to do, which was the whole problem.
What I expected
The setup is boring and probably familiar. A shell script calls a model in non-interactive mode, captures stdout, and writes the result to src/content/posts/<slug>.md. Astro picks it up from the content collection, Zod validates the frontmatter, and npm run build renders it.
Models like to wrap their output. You ask for "a complete markdown file, output only the markdown," and a meaningful fraction of the time you get back something shaped like this:
```markdown
title: "Whatever"
Post body here.
```
That outer fence is not part of the document. Write it to disk as-is and Astro sees a code block where the frontmatter should be, Zod finds no title, and the build fails loudly. Loud failures are fine. You fix them and move on.
So the pipeline stripped the wrapper before writing. That was the intent, and the expected outcome was a clean file with the outer fence gone and every internal code block untouched.
What actually happened
Here is the rule, as it existed in the generation script:
# The "cleanup" that caused the problem
claude -p "$PROMPT" | sed 's/^```markdown$//; s/^```$//' > "$OUT"
Read the second expression on its own: s/^`$//. It replaces any line consisting of exactly three backticks with an empty line. Every line. Not the last one, not the wrapper's closer specifically, all of them.
Markdown fences are a toggle, not a container. An opening fence with an info string (bash `) turns code mode on. A bare closing fence turns it off. Delete every bare closing fence in a document and you have deleted every "off" switch while keeping every "on" switch.
Corruption follows deterministically from there. The first `bash in the post opens a code block, and that block never closes. Every heading, paragraph, table, and link after it becomes code block content. The renderer is behaving correctly. It was handed a document that says, in effect, "the rest of this file is bash."
Nothing about the file is malformed by CommonMark rules, which is why it looked like corrupted prose rather than a syntax error. An unclosed fence at end-of-document is legal and closes implicitly. The parser doesn't complain, the markdown linter doesn't complain, and Astro renders it happily into a page nobody would read past the first screenful.
That first expression, s/^markdown$//, carried a smaller version of the same flaw. It emptied any line that was exactly markdown, which is a legitimate thing to write in a post about markdown tooling. Delete that opener and you're left with an orphaned closer, which then opens a code block for the remainder of the file. Same failure, inverted.
Two things kept this from being obvious for longer than I'd like.
Local models mostly didn't trigger it. The Ollama-backed path had different post-processing and rarely emitted the markdown wrapper in the first place, which is the kind of behavioral difference you don't discover until you [move a scheduled generation job between backends](https://guatulabs.dev/posts/moving-scheduled-llm-curation-from-cloud-apis-to-local-models/). So the sed` rule was written for a problem that mostly existed on one branch of the pipeline, and it damaged output on both.
Beyond that, nothing in the pipeline had an opinion about document structure. It checked word count. It checked frontmatter fields. It ran an anti-slop word filter. Every one of those checks passes cleanly on a file whose entire body is trapped inside a code block, because none of them parse the document as a document. When the only signal your automation emits is an exit code, you get told "success" for an entire class of failures that never touches the exit code. Structural validity has to be asserted explicitly, the same way you assert that a Kubernetes manifest is valid before it merges rather than after it's applied to a live cluster.
The fix, part one: stop using substitution for a positional problem
The wrapper is a positional artifact. It lives on line 1 and on the final non-empty line, and nowhere else. A substitution rule anchored with ^...$ has no concept of position, so it was the wrong tool from the first commit.
What replaced it matches on a signature rather than a pattern, and touches exactly two lines:
unwrap_markdown_fence() {
local file="$1"
local l1 l2 last lastno fences
l1=$(sed -n '1p' "$file")
l2=$(sed -n '2p' "$file")
lastno=$(grep -n '.' "$file" | tail -n1 | cut -d: -f1)
last=$(sed -n "${lastno}p" "$file")
# Signature of a whole-document wrapper:
# line 1 is a fence, line 2 opens frontmatter, last non-empty line closes it.
case "$l1" in
'```markdown'|'```md'|'```') ;;
*) return 0 ;;
esac
[[ "$l2" == '---' ]] || return 0
[[ "$last" == '```' ]] || return 0
# Even fence count means the wrapper pairs cleanly with itself.
fences=$(grep -c '^```' "$file")
(( fences % 2 == 0 )) || return 0
sed -i "1d;${lastno}d" "$file"
}
Checking line 2 for --- is what makes this safe. A wrapped document always has the frontmatter delimiter immediately after the opener, because that's how every post in the collection starts. A legitimate post that happens to open with a code fence will not have --- sitting on line 2.
Every guard clause returns 0 instead of failing. If the signature doesn't match, the function does nothing and the file passes through untouched. A cleanup step should be inert when it isn't confident, not aggressive. The old rule was the opposite: maximally confident, zero context.
The fix, part two: assert structure, don't count it
Unwrapping correctly is necessary but not sufficient, because the model itself can drop a closing fence. Any repair you write can also have bugs. So the pipeline needed something that reads the finished file and asserts the body is actually prose.
My first instinct was to count fences and check for an even number. That check is close to worthless. A document with a missing closer and a stray opener has an even count and is still broken, and a post that legitimately uses four-backtick fences to demonstrate nested markdown throws the count off entirely. Counting tells you about symbols. You need to know about state.
So check-fences.sh walks the file, tracks whether it's inside a code block, and looks for content that has no business being there:
#!/usr/bin/env bash
set -euo pipefail
file="$1"
awk '
/^```/ {
if (!in_code) { in_code=1; open_line=NR } else { in_code=0 }
next
}
# Markdown structures that should never appear inside a fence
in_code && /^#{1,6} / {
printf "line %d: heading inside code block opened at line %d\n", NR, open_line
bad=1
}
in_code && /^\|.*\|$/ {
printf "line %d: table row inside code block opened at line %d\n", NR, open_line
bad=1
}
END {
if (in_code) {
printf "unclosed fence opened at line %d (EOF)\n", open_line
bad=1
}
exit bad
}
' "$file"
Heading detection is what actually catches the sed bug. A swallowed document always contains ## Something inside the runaway block, because posts have sections. Table rows catch the same failure in posts built around comparison tables. The end-of-file check catches the simpler case where the model just forgot a closer.
False positives exist and I decided to live with them. A shell script demonstrating comment syntax can contain a line starting with #, but the awk pattern requires a space after one to six hashes at column zero, and code comments in the posts I generate rarely look like a markdown heading with a capitalized sentence after it. When it does misfire, the failure mode is a rejected draft rather than a published page of gray monospace, and I'll take that trade every time.
The fix, part three: repair before you reject
A validator that only says no creates its own failure mode. The instinct when a generation step fails is to retry, but retrying an expensive model call to fix a mechanical, deterministic defect is the wrong shape of solution. Worse, the regenerated draft is a different draft. You've thrown away good prose to fix a missing three-character line, and the new attempt can trip a different gate entirely.
Corruption from a missing closer has exactly one correct repair, so repair-fences.sh performs it directly and runs before the check:
#!/usr/bin/env bash
set -euo pipefail
file="$1"
awk '
/^```/ { in_code = !in_code; print; next }
# A heading inside a code block means the previous fence never closed.
in_code && /^#{1,6} / { print "```"; in_code=0; print; next }
{ print }
END { if (in_code) print "```" }
' "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
Ordering matters here. Repair runs first, the checker runs second, and the checker is the gate. If repair does its job, the check passes and the draft survives. If repair produces something still structurally broken, the check fails and the pipeline stops with a specific line number instead of a shrug. Repair is allowed to be optimistic precisely because it isn't the thing making the final call. That separation of "attempt a fix" from "verify the fix" is the same discipline that makes plan-and-apply workflows trustworthy in infrastructure automation, and it applies just as well to a text pipeline.
One caveat worth stating plainly: a repair step that mutates content is a liability if you can't see what it did. Mine writes a line to the run log whenever it changes the file, including the line number where it inserted a closer. Silent auto-repair is how you end up debugging a bug that a script already "fixed" three stages earlier.
Where the gates fight each other
Content quality gates and structural gates want different things, and stacking them naively produces a pipeline that rejects everything.
My anti-slop checker is a hard gate. Banned words, banned phrases, an em-dash budget, a rule against consecutive paragraphs opening with the same word. It's deterministic and it's strict, which is the point. But a hard gate on prose quality combined with a hard gate on structure means a draft has to clear both in a single generation, and the retry cost is a full regeneration.
The ordering that works for me: run mechanical repairs first (fence balance, wrapper stripping, frontmatter normalization), then run the structural gate, then run the quality gate, and only regenerate on quality failures. Mechanical problems get fixed in place because they have one right answer. Prose problems get sent back to the model with the specific violations included in the retry prompt, which turns a blind retry into a targeted revision. Feeding the checker's own output back into the next attempt is what makes the loop converge instead of thrashing, and it's the same feedback-shaped design that keeps multi-layer agent memory systems from degrading over repeated passes.
Anything a script can repair deterministically should never reach the model twice. That's the rule I landed on, and the fence bug is the clearest example of why: a missing ` cost an entire draft and a model call when a five-line awk program fixes it in milliseconds.
Why this matters beyond one blog
Text transformation with line-anchored regex is a trap any time the text has stateful syntax, and markdown is full of it. Fences toggle. List indentation nests. Frontmatter delimiters and horizontal rules are the same three characters in different positions. HTML comments and YAML block scalars both swallow content until a terminator. A sed expression sees a line. Your document has a grammar, and lines don't know what mode they're in.
Two habits keep this from biting:
- Match on position or signature, not on pattern, when the artifact is positional. If what you're stripping is "line 1 and the last line," write a rule that says line 1 and the last line. Anchors are not position.
- Assert the shape of the output, not just the presence of the output. Word count, file existence, and frontmatter fields all confirm that something landed on disk. None of them confirm it's a document. A structural check that understands the format is the only thing that closes the gap.
The broader pattern generalizes past blog tooling. Any pipeline that pipes model output through shell transformations and into a build system has this exposure, and the failure is quiet by construction: exit code 0, valid file, wrong document. If you're building automation where a model's output feeds downstream systems and you'd rather not discover the corruption from a reader, that validation layer is worth designing up front. It's a chunk of what I end up building for people in agent and pipeline work, and it's almost always cheaper than the cleanup.
What I'd do differently: I'd have written check-fences.sh before I wrote any transformation step at all. The validator is 20 lines of awk. The sed rule it protects against took a week of published output to notice, and noticing required a human looking at a rendered page. Automation that can't tell you it's broken isn't automation, it's a faster way to be wrong.
Top comments (0)