--- name: fix-markdown-fences version: 1.5.0 description: >- 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). license: MIT metadata: routing: role: nested-helper invoker: doc-accuracy trigger: doc-accuracy calls fix-markdown-fences to repair malformed code fences it finds user-facing: false --- # 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. 1. **Report.** Run the script over the target path. It prints every defect as `FILE:LINE: KIND: TEXT` and exits 1 when it finds any. ```bash python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" FILE_OR_DIR ``` 2. **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. 3. **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: ```bash python3 "${COPILOT_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT:-.claude}}/skills/fix-markdown-fences/scripts/fix_fences.py" FILE_OR_DIR --write ``` 4. **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. ```bash 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]: ` follows paragraph text, which CommonMark does not read as a block. A refused write costs a manual edit. 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 the repair function appends a closer to a balanced document. Five of twelve measured shapes do that. Where `markdown-it-py` is installed, `--write` refuses all five and writes none (see "Write guard"); without it the five are written. 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. Where `markdown-it-py` is installed, `--write` refuses all nine and writes none (see "Write guard"); without it the nine are written. And a setext `===` underline directly under a list item, followed by a lazy continuation and then an indented fence, leaves that fence unseen. The repair function is DESTRUCTIVE here, and it was recorded 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 the repair appends a second one at top level. Where `markdown-it-py` is installed, `--write` refuses all six of those files and writes none (see "Write guard"); the seventh rewritten shape is a genuinely unclosed file and is repaired correctly. Without the package the six are written. `---` 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. With `--write`, at least one file was refused (see "Write guard"); nothing was written for it - `2` a requested path does not exist, or a file could not be read or written ### Write guard `--write` runs two checks before it replaces a file. Either one refuses it: the file is not written, it is listed on a `Refused` line with the reason, the `refused` key under `--json` names it and `refusal_reasons` says why, and the run exits 1. Other files in the same run are still repaired. When a file is refused, read the report and fix the fences by hand. **A fence inside a raw HTML block.** CommonMark reads the fence as HTML, so the document is complete, while the scanner reads a fence and would append a closer. The edit lands on a well-formed file. The scanner does not model HTML blocks, so a file with a fence-looking line inside one is refused. This check needs no extra package. Fences inside a fenced block are skipped, so HTML shown as an example does not count, and an HTML block that a blank line or its own end marker closes before the fence does not count either. A block inside a list item or a blockquote is not seen. **A repair that leaves a fence open.** A repair must never turn a balanced file into an unclosed one. The worst case is a mistaken closer next to a raw HTML block: the repair inserts a closing fence above the info-string fence, then appends another at the end of the file, and the appended one is a fresh opener that nothing closes. A tracked file did exactly that. So `--write` checks the repaired text against `markdown-it-py`. A file that was balanced and would come out unclosed is refused. A file that was already unclosed is never refused, since repair cannot make it worse by that measure. `markdown-it-py` is optional because this skill is vendored into trees that carry no third-party packages. Without it this second check is skipped, stderr says so, and the HTML check still runs. Report mode never needs either. ## 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: 1. **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. 2. **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. 3. **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. 4. **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. 5. **Detect valid closing fence**: the same character at the same length or longer with an empty info string. Exit the block. 6. **At end of file**: a still-open block gets a bare closing fence appended. ## Verification ```bash 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 ``` - [ ] The script exits 0 on the repaired path. - [ ] `git diff` shows only fence lines added, no content modifications. - [ ] Defects that belong inside a wider container fence were widened by hand rather than closed by `--write`. ## 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: 1. 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. 2. Never copy the opening fence line to close 3. 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.
Implementation: Bash (Quick Check) ```bash # 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.
Edge Cases Handled 1. **Nested indentation**: Preserves indent level from opening fence 2. **Multiple consecutive blocks**: Each block tracked independently 3. **File ending inside block**: Closes an unclosed block with a fence of the same character and length 4. **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