Fix Markdown Code Fence Closings
Scan and repair malformed closing fences in markdown files. Closing fences must never contain language identifiers.
Triggers
| Trigger Phrase |
Operation |
fix markdown fences |
Scan and repair malformed fence closings |
repair code block closings |
Fix closing fences with language identifiers |
markdown rendering broken |
Diagnose and fix fence issues |
code blocks bleeding into content |
Fix unclosed or malformed fences |
validate markdown code blocks |
Check all fences for correctness |
Quick Reference
| Symptom |
Cause |
Fix |
| Code block bleeds into text |
Closing fence has language identifier |
Insert a bare closing fence above it; the line then opens the next block |
| Nested blocks render wrong |
Missing closing fence before new opening |
Insert closing fence |
| Content cut off at end of file |
Unclosed code block |
Append closing fence |
When to Use
Use this skill when:
- Markdown code blocks render incorrectly or bleed into surrounding content
- Closing fences have language identifiers (e.g.,
```python instead of ```)
- Validating markdown documentation before committing
Use manual editing instead when:
- The issue is indentation or content inside the code block (not the fences)
- You need to change the language identifier on opening fences
Process
Do not walk the file by hand. Fence tracking is a state machine, and
fix_fences.py runs it.
- Report. Run the script over the target path. It prints every defect as
FILE:LINE: KIND: TEXT and exits 1 when it finds any.
python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" FILE_OR_DIR
Read the report. Two kinds appear:
malformed_closing: a closing fence carries a language identifier, so
the block bleeds into the following prose.
unclosed_block: the file ends with a block still open.
Decide, then write. Repair is best-effort on an ambiguous file. When a
defect cluster sits inside documentation that shows fenced markdown, the
author usually wanted a wider container fence (four backticks around a
three-backtick example), not the closing fence the repair inserts. Widen
the container by hand in that case. Otherwise:
python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" FILE_OR_DIR --write
- Confirm. Re-run step 1. A clean tree exits 0. Then read
git diff and
confirm only fence lines moved.
Scripts
fix_fences.py
Detects and repairs malformed fence closings. Reporting is the default;
--write is required to modify a file.
python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" PATH [PATH ...]
python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" PATH --write
python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" PATH --json
Options: --write repairs in place, --json emits machine-readable output,
--pattern sets the glob for directory scans (default *.md). Paths default
to the current directory. .git, node_modules, .venv, venv, and __pycache__
are skipped.
Fence matching follows CommonMark, which is what keeps the tool from damaging
documentation:
- A fence is three or more backticks or three or more tildes.
- A closing fence uses the same character and is at least as long as the
opener, so a three-backtick example nested inside a four-backtick container
stays literal text.
- A backtick opening fence whose info string contains a backtick is not a
fence.
- A marker more than three spaces past its containing block is an indented
code block, not a fence. That is what stops a repair from appending a fence
to a document that shows a bare fence inside an indented block. The three
spaces are counted from the innermost open list item, not from column zero,
so a fence indented four spaces inside a list item is still a fence.
- A list item's content column is not always the marker plus its padding. Five
or more spaces after the marker means the content column is the marker plus
one; an item with no content on its line is the same. A marker that is itself
indented code opens no item, a thematic break is never an item even though
* * * matches the bullet grammar, and a list may interrupt a paragraph only
when the item is non-empty and, if ordered, starts at 1 (leading zeros do not
change that start). That last veto is scoped to the item the paragraph lives
in: a marker indented below the content column closes the item, the paragraph
closes with it, and the marker is then judged at the outer level where no
paragraph is open. A link reference definition ([foo]: /url) is its own
block and leaves no paragraph open, so a list may start after one; but a
definition cannot interrupt a paragraph that is already open, its
destination and title must be complete ([foo]: <broken is prose), its
destination balances parentheses at any depth and may escape either
angle delimiter while a title may escape its own and may run across
lines until that delimiter arrives, a continuation belongs to the same
leaf block and so keeps its meaning at any indent, and
either the destination or a bare title may sit on the following line, and
a blank line, a fenced block, a list marker or an indented code block
cancels a definition still waiting for one, while a line that does
continue one is a lazy continuation and does not close the item holding
it. A label of only whitespace is not a definition at all. A blank line
directly after an empty marker closes the item, and a paragraph
continuation line may dedent without closing it.
Getting any of these wrong moves the content column, which moves what counts
as a fence.
- A marker line's remainder is re-parsed inside the item it opens, so a fence
marker after a bullet opens a block whose indent is the item's content
column, and
- - a opens two items. A block also ends when the item holding
it ends, with no closing marker, so a line that dedents below it closes it.
Without either rule the tool kept a block open past its real end, and
--write appended a closing fence to documents already well formed.
- Known gaps, each measured and none counted. A raw HTML block swallows a
following fence, so a fence inside one is read as a fence here and as HTML
by CommonMark. That one is DESTRUCTIVE, not merely a disagreement, and it is
the worst gap on this list: the reference parser sees no fence at all, so the
document is balanced, and
--write appends a closer anyway. Measured across
all seven CommonMark HTML block types, opener terminated and unterminated,
20 of 20 shapes are written to. Do not run --write unattended over
documents containing raw HTML blocks. A blockquote prefix is never stripped,
and that costs two different things. A fence inside > is invisible, which is a miss: six
shapes diverge and --write changes none. A blockquote INTERRUPTING a
paragraph is worse: CommonMark ends the paragraph and lazily continues the
quote, so a following 2. opens a list, while we keep the paragraph open
and --write appends a closer to a balanced document. Two of twelve
measured shapes do that. A backslash in a link destination escapes whatever
follows it, where CommonMark escapes only ASCII punctuation, so an escaped
space and an escaped tab are read wrongly: 62 of 64 shapes agree. The spec
rule was measured before being believed and is worse, 34 of 64 with 30
--write corruptions, so the permissive rule stays. The escaped-TAB half is
destructive on its own account, because tabs are expanded to four-column
stops before the grammar sees them, so what the permissive rule eats depends
on the label's length: nine of eleven single-line label lengths turn a
balanced document into an unclosed one, and the two that do not are the two
that land on a tab stop. Escaped space and the next-line destination path
corrupt none of eleven. And a setext ===
underline
directly under a list item, followed by a lazy continuation and then an
indented fence, leaves that fence unseen. This one is DESTRUCTIVE too, and
it was recorded here as a miss on a measurement that did not hold: over nine
shapes, seven are rewritten and six go in balanced and come out unclosed.
The smallest is five lines, - item / === / lazy / two-space fence /
out, where the list item ending closes the fence for CommonMark and
--write appends a second one at top level. --- under
the same item already agrees. The scanners' agreement with a CommonMark
reference is measured by the fuzz baselines in the repository's test suite.
Files are read and written as bytes, so CRLF and CR endings survive, a UTF-8
BOM survives, and every separator str.splitlines would swallow (U+000B,
U+000C, U+001C, U+001D, U+001E, U+0085, U+2028, U+2029) stays put. Each line keeps its own terminator, so
a mixed-ending file is not normalized. Repair is idempotent.
Exit codes (ADR-035):
0 no defects found, or --write repaired every defect it found
1 report mode found at least one defect; nothing was written
2 a requested path does not exist, or a file could not be read or written
Reference: the algorithm
Kept so a reader can audit the script, not so the agent can run it by hand.
Track fence state while scanning line by line:
- Track list containers: outside a block, close any list item the line
has dedented out of, and close any paragraph those items held, then open one
if the line starts an item. The stack of open content columns is what the
indent test below measures against. Closing the paragraph with its item is
what lets a marker that cannot interrupt a paragraph still open a list after
a dedent.
- Detect opening fence: outside a block, a line of three or more
backticks or tildes, indented no more than three columns past the innermost
open list item, opens one. Record the character, the length, and the indent.
- Detect the end of the containing item: inside a block that opened
inside a list item, a line indented below that item's content column ends
the block, with no closing marker and nothing inserted.
- Detect malformed closing fence: inside a block, a line using the same
character at the same length or longer, carrying a non-empty info string.
Insert a bare closing fence before it.
- Detect valid closing fence: the same character at the same length or
longer with an empty info string. Exit the block.
- At end of file: a still-open block gets a bare closing fence appended.
Verification
python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" PATH
echo "exit=$?" # 0 = clean, 1 = defects found, 2 = bad input
Anti-Patterns
| Avoid |
Why |
Instead |
| Manually searching for bad fences |
Error-prone in large files, and the agent cannot track fence length by eye |
Run fix_fences.py |
| Simulating the state machine in-context |
The script already does it, exactly and for free |
Read the script's report |
Running --write across a whole repo unreviewed |
A repair inside nested documentation is often the wrong fix |
Report first, review, then write per path |
| Copying opening fence line to close a block |
Creates the exact bug this skill fixes |
Close with the opener's character, no info string, at least the opener's length |
| Fixing fences without tracking block state |
Misidentifies nested vs sequential blocks |
Run fix_fences.py, which tracks it |
Prevention
When generating markdown with code blocks:
- Close with the opener's fence character and no info string, at a length
at least the opener's. A four-backtick container needs four to close it,
which is what lets it hold a three-backtick example.
- Never copy the opening fence line to close
- Track block state when programmatically generating markdown
The shipped script does this for you; fix_fences.py is the
implementation, and the Reference section above is the algorithm it runs. An
earlier revision of this file inlined a copy of that parser here under
"Implementation: Python (Recommended)". It was the pre-CommonMark version,
which had no fence-length rule and so corrupted any document showing a
three-backtick example inside a four-backtick container. It is gone rather
than fixed: a second copy of a parser drifts from the one that ships.
# Find files with potential issues
# Single quotes throughout: a backtick inside DOUBLE quotes opens command
# substitution, and this line used to fail `bash -n` for that reason.
grep -rEn --include="*.md" -- '```\w+' . | grep -vE '^[^:]*:[0-9]*:[[:space:]]*```\w+[[:space:]]*$'
An earlier revision also inlined a PowerShell rewriter here. It is gone for
the same reason, and measurement is why rather than symmetry: run on a CRLF
file it rewrote every ending to LF, which the Edge Cases list below
explicitly promises it does not do, and a ~~~python block left unclosed
was invisible to it because it matched backticks only. The shipped script
closes that tilde block and preserves the CRLF. A copy that drifts this far
while sitting under the same heading is worse than no example.
- Nested indentation: Preserves indent level from opening fence
- Multiple consecutive blocks: Each block tracked independently
- File ending inside block: Closes an unclosed block with a fence of the same character and length
- Mixed line endings:
\n, \r\n and \r are preserved per line, as is a UTF-8 BOM and the presence or absence of a trailing newline
1---2name: fix-markdown-fences3description: Repair malformed markdown code fence closings. Use when you say "fix markdown fences", "repair code block closings", "markdown rendering broken", "code blocks bleeding into content", or "validate markdown code blocks" on any .md file. Do NOT use for documentation accuracy checks or verifying code examples (use doc-accuracy).4license: MIT5---67# Fix Markdown Code Fence Closings89Scan and repair malformed closing fences in markdown files. Closing fences must never contain language identifiers.1011## Triggers1213| Trigger Phrase | Operation |14|----------------|-----------|15| `fix markdown fences` | Scan and repair malformed fence closings |16| `repair code block closings` | Fix closing fences with language identifiers |17| `markdown rendering broken` | Diagnose and fix fence issues |18| `code blocks bleeding into content` | Fix unclosed or malformed fences |19| `validate markdown code blocks` | Check all fences for correctness |2021## Quick Reference2223| Symptom | Cause | Fix |24|---------|-------|-----|25| Code block bleeds into text | Closing fence has language identifier | Insert a bare closing fence above it; the line then opens the next block |26| Nested blocks render wrong | Missing closing fence before new opening | Insert closing fence |27| Content cut off at end of file | Unclosed code block | Append closing fence |2829## When to Use3031**Use this skill when:**3233- Markdown code blocks render incorrectly or bleed into surrounding content34- Closing fences have language identifiers (e.g., ` ```python ` instead of ` ``` `)35- Validating markdown documentation before committing3637**Use manual editing instead when:**3839- The issue is indentation or content inside the code block (not the fences)40- You need to change the language identifier on opening fences4142## Process4344Do not walk the file by hand. Fence tracking is a state machine, and45`fix_fences.py` runs it.46471. **Report.** Run the script over the target path. It prints every defect as48 `FILE:LINE: KIND: TEXT` and exits 1 when it finds any.4950```bash51python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" FILE_OR_DIR52```53542. **Read the report.** Two kinds appear:55 - `malformed_closing`: a closing fence carries a language identifier, so56 the block bleeds into the following prose.57 - `unclosed_block`: the file ends with a block still open.58593. **Decide, then write.** Repair is best-effort on an ambiguous file. When a60 defect cluster sits inside documentation that shows fenced markdown, the61 author usually wanted a wider container fence (four backticks around a62 three-backtick example), not the closing fence the repair inserts. Widen63 the container by hand in that case. Otherwise:6465```bash66python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" FILE_OR_DIR --write67```68694. **Confirm.** Re-run step 1. A clean tree exits 0. Then read `git diff` and70 confirm only fence lines moved.7172## Scripts7374### fix_fences.py7576Detects and repairs malformed fence closings. Reporting is the default;77`--write` is required to modify a file.7879```bash80python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" PATH [PATH ...]81python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" PATH --write82python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" PATH --json83```8485Options: `--write` repairs in place, `--json` emits machine-readable output,86`--pattern` sets the glob for directory scans (default `*.md`). Paths default87to the current directory. `.git`, `node_modules`, `.venv`, `venv`, and `__pycache__`88are skipped.8990Fence matching follows CommonMark, which is what keeps the tool from damaging91documentation:9293- A fence is three or more backticks or three or more tildes.94- A closing fence uses the same character and is at least as long as the95 opener, so a three-backtick example nested inside a four-backtick container96 stays literal text.97- A backtick opening fence whose info string contains a backtick is not a98 fence.99- A marker more than three spaces past its containing block is an indented100 code block, not a fence. That is what stops a repair from appending a fence101 to a document that shows a bare fence inside an indented block. The three102 spaces are counted from the innermost open list item, not from column zero,103 so a fence indented four spaces inside a list item is still a fence.104- A list item's content column is not always the marker plus its padding. Five105 or more spaces after the marker means the content column is the marker plus106 one; an item with no content on its line is the same. A marker that is itself107 indented code opens no item, a thematic break is never an item even though108 `* * *` matches the bullet grammar, and a list may interrupt a paragraph only109 when the item is non-empty and, if ordered, starts at 1 (leading zeros do not110 change that start). That last veto is scoped to the item the paragraph lives111 in: a marker indented below the content column closes the item, the paragraph112 closes with it, and the marker is then judged at the outer level where no113 paragraph is open. A link reference definition (`[foo]: /url`) is its own114 block and leaves no paragraph open, so a list may start after one; but a115 definition cannot interrupt a paragraph that is already open, its116 destination and title must be complete (`[foo]: <broken` is prose), its117 destination balances parentheses at any depth and may escape either118 angle delimiter while a title may escape its own and may run across119 lines until that delimiter arrives, a continuation belongs to the same120 leaf block and so keeps its meaning at any indent, and121 either the destination or a bare title may sit on the following line, and122 a blank line, a fenced block, a list marker or an indented code block123 cancels a definition still waiting for one, while a line that does124 continue one is a lazy continuation and does not close the item holding125 it. A label of only whitespace is not a definition at all. A blank line126 directly after an empty marker closes the item, and a paragraph127 continuation line may dedent without closing it.128 Getting any of these wrong moves the content column, which moves what counts129 as a fence.130- A marker line's remainder is re-parsed inside the item it opens, so a fence131 marker after a bullet opens a block whose indent is the item's content132 column, and `- - a` opens two items. A block also ends when the item holding133 it ends, with no closing marker, so a line that dedents below it closes it.134 Without either rule the tool kept a block open past its real end, and135 `--write` appended a closing fence to documents already well formed.136- Known gaps, each measured and none counted. A raw HTML block swallows a137 following fence, so a fence inside one is read as a fence here and as HTML138 by CommonMark. That one is DESTRUCTIVE, not merely a disagreement, and it is139 the worst gap on this list: the reference parser sees no fence at all, so the140 document is balanced, and `--write` appends a closer anyway. Measured across141 all seven CommonMark HTML block types, opener terminated and unterminated,142 20 of 20 shapes are written to. Do not run `--write` unattended over143 documents containing raw HTML blocks. A blockquote prefix is never stripped,144 and that costs two different things. A fence inside `>` is invisible, which is a miss: six145 shapes diverge and `--write` changes none. A blockquote INTERRUPTING a146 paragraph is worse: CommonMark ends the paragraph and lazily continues the147 quote, so a following `2.` opens a list, while we keep the paragraph open148 and `--write` appends a closer to a balanced document. Two of twelve149 measured shapes do that. A backslash in a link destination escapes whatever150 follows it, where CommonMark escapes only ASCII punctuation, so an escaped151 space and an escaped tab are read wrongly: 62 of 64 shapes agree. The spec152 rule was measured before being believed and is worse, 34 of 64 with 30153 `--write` corruptions, so the permissive rule stays. The escaped-TAB half is154 destructive on its own account, because tabs are expanded to four-column155 stops before the grammar sees them, so what the permissive rule eats depends156 on the label's length: nine of eleven single-line label lengths turn a157 balanced document into an unclosed one, and the two that do not are the two158 that land on a tab stop. Escaped space and the next-line destination path159 corrupt none of eleven. And a setext `===`160 underline161 directly under a list item, followed by a lazy continuation and then an162 indented fence, leaves that fence unseen. This one is DESTRUCTIVE too, and163 it was recorded here as a miss on a measurement that did not hold: over nine164 shapes, seven are rewritten and six go in balanced and come out unclosed.165 The smallest is five lines, `- item` / `===` / `lazy` / two-space fence /166 `out`, where the list item ending closes the fence for CommonMark and167 `--write` appends a second one at top level. `---` under168 the same item already agrees. The scanners' agreement with a CommonMark169 reference is measured by the fuzz baselines in the repository's test suite.170171Files are read and written as bytes, so CRLF and CR endings survive, a UTF-8172BOM survives, and every separator `str.splitlines` would swallow (U+000B,173U+000C, U+001C, U+001D, U+001E, U+0085, U+2028, U+2029) stays put. Each line keeps its own terminator, so174a mixed-ending file is not normalized. Repair is idempotent.175176Exit codes (ADR-035):177178- `0` no defects found, or `--write` repaired every defect it found179- `1` report mode found at least one defect; nothing was written180- `2` a requested path does not exist, or a file could not be read or written181182## Reference: the algorithm183184Kept so a reader can audit the script, not so the agent can run it by hand.185186Track fence state while scanning line by line:1871881. **Track list containers**: outside a block, close any list item the line189 has dedented out of, and close any paragraph those items held, then open one190 if the line starts an item. The stack of open content columns is what the191 indent test below measures against. Closing the paragraph with its item is192 what lets a marker that cannot interrupt a paragraph still open a list after193 a dedent.1942. **Detect opening fence**: outside a block, a line of three or more195 backticks or tildes, indented no more than three columns past the innermost196 open list item, opens one. Record the character, the length, and the indent.1973. **Detect the end of the containing item**: inside a block that opened198 inside a list item, a line indented below that item's content column ends199 the block, with no closing marker and nothing inserted.2004. **Detect malformed closing fence**: inside a block, a line using the same201 character at the same length or longer, carrying a non-empty info string.202 Insert a bare closing fence before it.2035. **Detect valid closing fence**: the same character at the same length or204 longer with an empty info string. Exit the block.2056. **At end of file**: a still-open block gets a bare closing fence appended.206207## Verification208209```bash210python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" PATH211echo "exit=$?" # 0 = clean, 1 = defects found, 2 = bad input212```213214- [ ] The script exits 0 on the repaired path.215- [ ] `git diff` shows only fence lines added, no content modifications.216- [ ] Defects that belong inside a wider container fence were widened by217 hand rather than closed by `--write`.218219## Anti-Patterns220221| Avoid | Why | Instead |222|-------|-----|---------|223| Manually searching for bad fences | Error-prone in large files, and the agent cannot track fence length by eye | Run `fix_fences.py` |224| Simulating the state machine in-context | The script already does it, exactly and for free | Read the script's report |225| Running `--write` across a whole repo unreviewed | A repair inside nested documentation is often the wrong fix | Report first, review, then write per path |226| Copying opening fence line to close a block | Creates the exact bug this skill fixes | Close with the opener's character, no info string, at least the opener's length |227| Fixing fences without tracking block state | Misidentifies nested vs sequential blocks | Run `fix_fences.py`, which tracks it |228229## Prevention230231When generating markdown with code blocks:2322331. Close with the opener's fence character and no info string, at a length234 at least the opener's. A four-backtick container needs four to close it,235 which is what lets it hold a three-backtick example.2362. Never copy the opening fence line to close2373. Track block state when programmatically generating markdown238239The shipped script does this for you; `fix_fences.py` is the240implementation, and the Reference section above is the algorithm it runs. An241earlier revision of this file inlined a copy of that parser here under242"Implementation: Python (Recommended)". It was the pre-CommonMark version,243which had no fence-length rule and so corrupted any document showing a244three-backtick example inside a four-backtick container. It is gone rather245than fixed: a second copy of a parser drifts from the one that ships.246247<details>248<summary><strong>Implementation: Bash (Quick Check)</strong></summary>249250```bash251# Find files with potential issues252# Single quotes throughout: a backtick inside DOUBLE quotes opens command253# substitution, and this line used to fail `bash -n` for that reason.254grep -rEn --include="*.md" -- '```\w+' . | grep -vE '^[^:]*:[0-9]*:[[:space:]]*```\w+[[:space:]]*$'255```256257</details>258259An earlier revision also inlined a PowerShell rewriter here. It is gone for260the same reason, and measurement is why rather than symmetry: run on a CRLF261file it rewrote every ending to LF, which the Edge Cases list below262explicitly promises it does not do, and a `~~~python` block left unclosed263was invisible to it because it matched backticks only. The shipped script264closes that tilde block and preserves the CRLF. A copy that drifts this far265while sitting under the same heading is worse than no example.266267<details>268<summary><strong>Edge Cases Handled</strong></summary>2692701. **Nested indentation**: Preserves indent level from opening fence2712. **Multiple consecutive blocks**: Each block tracked independently2723. **File ending inside block**: Closes an unclosed block with a fence of the same character and length2734. **Mixed line endings**: `\n`, `\r\n` and `\r` are preserved per line, as is a UTF-8 BOM and the presence or absence of a trailing newline274275</details>