Auto-detect and add missing language tags to generated markdown code blocks
Missing Language Tags and Inconsistent Markdown Formatting in Generated Files
Environment
- Platform (select one):
- [x] Anthropic API
- [ ] AWS Bedrock
- [ ] Google Vertex AI
- [ ] Other: <!-- specify -->
- Claude CLI version: <!-- Latest available version -->
- Operating System: <!-- macOS, Windows, Linux - affects all platforms -->
- Terminal: <!-- iTerm2, Terminal App, VSCode Terminal - affects all terminals -->
Summary / Why This Matters
Unlabelled code fences and inconsistent spacing force users to request full rewrites instead of targeted edits. This increases turns, tokens, and the likelihood of formatting regressions. Auto-labelling fences and normalising spacing unlock surgical edits, safer diffs, and higher-quality docs with minimal runtime overhead.
Projected impact (order-of-magnitude):
- Turns per edit task: ↓ 20–40%
- Tokens per iteration: ↓ 10–25%
- Formatting regressions inside fences: ↓ to ~0% (fence-protected)
- Runtime overhead (client-side preproc): +2–8 ms/MB CPU (O(n); negligible vs I/O/model latency)
Bug Description
Claude Code sometimes generates Markdown with missing language tags on code fences and inconsistent line spacing, which breaks syntax highlighting, hurts readability, and complicates downstream processing.
Steps to Reproduce
- Ask Claude Code to create any markdown file containing code blocks.
- Request code examples in JavaScript, Python, PowerShell, etc.
- Inspect the generated markdown file.
- Check code fences after triple back-ticks for language tags.
Expected Behaviour
Code blocks include proper language tags and spacing:
function example() {
return "hello";
}
- Syntax highlighting works across GitHub/editors.
- Blank lines separate sections consistently (CommonMark-friendly).
Actual Behaviour
Code blocks are generated without language tags:
function example() {
return "hello";
}
This results in:
- No syntax highlighting in GitHub/editors
- Poor readability due to inconsistent spacing
- Incompatibility with processors expecting language tags
- Documentation that doesn't meet professional standards
User & Model Efficiency Rationale
- User pain: Long Markdown with unlabeled fences makes targeted edits hard; users resort to full rewrites.
- Model efficiency: Labeled fences + stable structure allow scoped edits instead of re-summarising entire docs.
- Supportability: Fence-safe spacing eliminates "it broke my code formatting" complaints; diffs remain tight.
Suggested Technical Fixes
MVP (Pre-processor + Safe Spacing)
- Language Detection Pipeline
- Lightweight heuristics (keywords/imports, shebangs, JSON parse attempt).
- Use conversation context as a soft prior; fall back to
text.
- Markdown Post-Processing
- Add language tags to unlabeled fenced blocks.
- Apply spacing normalisation only outside code fences.
- Validate output against a markdown linter (non-blocking).
- Configuration Enhancement
Extend CLAUDE.md to support formatting preferences:
# Markdown Formatting
- code_block_language_detection: true
- enforce_commonmark_spacing: true
- default_code_language: "text"
Phase 2 (Optional, but high leverage)
- Stable Block Anchors & Targeted Editing
- Inject
<!-- mdblk:{hash} lang:python name:optional -->above each fence (hash of normalised code). - Add CLI/editor support for scoped edits:
--targets @block(mdblk:abcd1234)or@lang(python)[2]. - Side-panel outline of blocks by lang/name/hash for quick jump and diff-safe apply.
Performance & Efficiency Estimates
- Runtime (per ~1 MB file): ~5–20 ms total on CPython (vs ~3–12 ms today).
Overhead ~1.3–1.8×, still negligible for human workflows.
- Memory peak: ~2–4× file size (string copies & regex slices), typical for text filters.
- Detection accuracy: ~70–95% across common languages; YAML/ambiguous snippets at the low end. Mislabels are mostly harmless and user-correctable.
Risks & Mitigations
- Mis-classification of language: Fall back to
text; allow user overrides; never mutate code content. - Anchor drift (Phase 2): Hash on normalised body; soft-match by header/lang; prompt to re-anchor when needed.
- Legacy/edge markdown: Parser treats indented fences and CRLF safely; no-ops if conflicts detected.
Acceptance Criteria
- All generated code fences have a language tag or
textfallback. - Spacing normalisation never alters code within fences.
- Unit tests cover: indented fences, CRLF, missing trailing newline, JSON/YAML ambiguity, large files.
- Optional linter pass reports (does not block) spacing/report-only findings.
Interim Workaround: Hook Implementation
Users can add a post-processing hook to fix markdown formatting. This implementation protects code blocks, handles indented fences/CRLF, and adds best-effort language tags.
File: .claude/hooks/markdown_formatter.py
````python
#!/usr/bin/env python3
"""
Markdown formatter for Claude Code output.
Fixes missing language tags and spacing issues while preserving code content.
"""
import re
import sys
import io
import json
from pathlib import Path
Regex to match fenced code blocks with proper indentation handling
CODE_FENCE_RE = re.compile(
r'(?ms)^([ \t]{0,3})``([^\n]*)\n(.*?)(\n\1``)[ \t]*\r?\n?'
)
def detect_language(code):
"""Best-effort language detection from code content."""
s = code.strip()
# JSON detection (try parsing first)
if re.search(r'^\s*[{\[]', s):
try:
json.loads(s)
return 'json'
except (ValueError, json.JSONDecodeError):
pass
# XML/HTML detection
if re.search(r'^\s<!DOCTYPE html>|^\s<html\b|^\s*<([a-zA-Z]+)(\s|>)', s):
return 'html'
if re.search(r'^\s<\?xml\b|^\s<\w+[:>]', s):
return 'xml'
# SQL detection
if re.search(r'\b(SELECT|INSERT|UPDATE|DELETE|CREATE)\s+', s, re.I) and ';' in s:
return 'sql'
# PowerShell detection (specific patterns to avoid false positives)
if re.search(r'\bfunction\s+\w+-\w+\b', s) or \
re.search(r'\b(Write-Host|Get-\w+|Set-\w+|New-\w+|Remove-\w+)\b', s) or \
re.search(r'\$\w+\s=.\s*-\w+', s):
return 'powershell'
# Bash/shell detection
if re.search(r'^#!.*\b(bash|sh|zsh)\b', s, re.M) or \
re.search(r'\b(if|then|fi|elif|case|esac|for|in|do|done)\b', s) or \
re.search(r'^\s*export\s+\w+=', s, re.M):
return 'bash'
# Python detection
if re.search(r'^\sdef\s+\w+\s\(', s, re.M) or \
re.search(r'^\s*(import|from)\s+\w+', s, re.M) or \
re.search(r'\bif\s+__name__\s==\s["\']__main__["\']', s):
return 'python'
# JavaScript/TypeScript detection
if re.search(r'\b(function\s+\w+\s\(|const\s+\w+\s=|let\s+\w+\s=|var\s+\w+\s=)', s) or \
re.search(r'=>|console\.(log|error|warn)', s):
return 'javascript'
# CSS detection
if re.search(r'[.#]\w+\s\{[^}]\}', s) or \
re.search(r'\w+\s:\s[^;]+;', s):
return 'css'
# YAML detection (after JSON check to avoid conflicts)
if re.search(r'(?m)^[ \t]\w[^:\n]:\s*\S', s) or \
re.search(r'(?m)^[ \t]*-\s+\S', s):
return 'yaml'
return 'text'
def add_lang_to_fence(match):
"""Add language tag to fenced code block if missing."""
indent, info, body, closing = match.groups()
info_str = info.strip()
# If language already present, leave as-is
if info_str and re.match(r'^[A-Za-z0-9_+.-]+', info_str):
return match.group(0)
lang = detect_language(body)
return f"{indent}```{lang}\n{body}{closing}\n"
def fix_spacing_outside_code(text):
"""Apply markdown spacing fixes only outside fenced code blocks."""
text = re.sub(r'\n{3,}', '\n\n', text) # Collapse 3+ blank lines
text = re.sub(r'(?m)^(#{1,6}\s+.+)\r?\n(?!\r?\n|$)', r'\1\n\n', text) # Space after headings
return text
def format_markdown(content):
"""Format markdown content with language detection and spacing fixes."""
# Add language tags to unlabeled code fences
content = CODE_FENCE_RE.sub(add_lang_to_fence, content)
# Fix spacing only outside code blocks
parts = []
last_end = 0
for match in CODE_FENCE_RE.finditer(content):
outside_text = content[last_end:match.start()]
parts.append(fix_spacing_outside_code(outside_text))
parts.append(match.group(0)) # preserve code fence
last_end = match.end()
parts.append(fix_spacing_outside_code(content[last_end:]))
result = ''.join(parts)
return result.rstrip() + '\n' # ensure single trailing newline
def main():
"""Main function with proper error handling."""
if len(sys.argv) < 2:
print("Usage: python3 markdown_formatter.py <file.md> [--in-place]")
sys.exit(1)
in_place = '--in-place' in sys.argv
filepath = next((arg for arg in sys.argv[1:] if not arg.startswith('-')), None)
if not filepath:
print("Error: No input file specified")
sys.exit(1)
try:
with io.open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
formatted = format_markdown(content)
if formatted == content:
print(f"No changes needed for '{filepath}'")
return
if in_place:
with io.open(filepath, 'w', encoding='utf-8') as f:
f.write(formatted)
print(f"Formatted '{filepath}' in place")
else:
sys.stdout.write(formatted)
except Exception as e:
print(f"Error processing file: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
````
Hook Configuration in Claude settings:
{
"hooks": {
"file_write": {
"*.md": [
"python3",
".claude/hooks/markdown_formatter.py",
"{file_path}",
"--in-place"
]
}
}
}
Telemetry & KPIs (for roll-out)
- % of generated Markdown with language-tagged fences
- Avg turns/task for Markdown edits (pre vs post)
- Token/turn for Markdown edit sessions
- Incidence of post-edit reformat complaints
- Fence mis-classification rate (sampled)
Labels: area:core, type:enhancement, priority:medium, good-first-issue
#
See also: #2373 (markdown formatting loops), #4004 (command line formatting), #3567 (markdown best practices)
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗