Skip to content

Commit 6db8c1e

Browse files
authored
Add script to check and fix Markdown heading case
This script checks and fixes the title case of ATX headings in Markdown files, allowing for an optional fix mode that modifies the files directly.
1 parent 65fe211 commit 6db8c1e

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

check_heading_case.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import argparse
2+
import re
3+
import sys
4+
from pathlib import Path
5+
6+
SMALL_WORDS = {
7+
"a", "an", "the",
8+
"and", "or", "but", "nor", "so", "yet",
9+
"as", "at", "by", "for", "from", "in", "into",
10+
"of", "on", "onto", "over", "per", "to", "up", "via", "with",
11+
}
12+
13+
ATX_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
14+
FENCE_RE = re.compile(r"^\s*(```|~~~)")
15+
FRONTMATTER_DELIM_RE = re.compile(r"^---\s*$")
16+
HEADING_ANCHOR_RE = re.compile(r"\s*\{#[\w-]+\}\s*$")
17+
INLINE_CODE_RE = re.compile(r"`[^`]*`")
18+
19+
20+
def split_words_preserving_delims(text):
21+
tokens = []
22+
for part in re.split(r"(\s+|-)", text):
23+
if part == "":
24+
continue
25+
is_word = bool(re.match(r"^[A-Za-z][A-Za-z0-9'’]*$", part))
26+
tokens.append((part, is_word))
27+
return tokens
28+
29+
30+
def title_case_word(word, is_first, is_last):
31+
lower = word.lower()
32+
if not is_first and not is_last and lower in SMALL_WORDS:
33+
return lower
34+
if word[0].isupper():
35+
return word
36+
return word[0].upper() + word[1:]
37+
38+
39+
def check_and_fix_heading(text):
40+
tokens = split_words_preserving_delims(text)
41+
word_positions = [i for i, (_, is_word) in enumerate(tokens) if is_word]
42+
if not word_positions:
43+
return False, text
44+
45+
first_idx = word_positions[0]
46+
last_idx = word_positions[-1]
47+
48+
new_tokens = list(tokens)
49+
changed = False
50+
for i, (tok, is_word) in enumerate(tokens):
51+
if not is_word:
52+
continue
53+
fixed = title_case_word(tok, i == first_idx, i == last_idx)
54+
if fixed != tok:
55+
changed = True
56+
new_tokens[i] = (fixed, True)
57+
58+
suggestion = "".join(t for t, _ in new_tokens)
59+
return changed, suggestion
60+
61+
62+
def extract_headings(lines):
63+
in_frontmatter = False
64+
in_fence = False
65+
results = []
66+
67+
for i, line in enumerate(lines):
68+
if i == 0 and FRONTMATTER_DELIM_RE.match(line):
69+
in_frontmatter = True
70+
continue
71+
if in_frontmatter:
72+
if FRONTMATTER_DELIM_RE.match(line):
73+
in_frontmatter = False
74+
continue
75+
76+
if FENCE_RE.match(line):
77+
in_fence = not in_fence
78+
continue
79+
if in_fence:
80+
continue
81+
82+
m = ATX_HEADING_RE.match(line)
83+
if not m:
84+
continue
85+
86+
hashes, raw_text = m.groups()
87+
text_for_check = HEADING_ANCHOR_RE.sub("", raw_text)
88+
code_spans = INLINE_CODE_RE.findall(text_for_check)
89+
placeholder_text = INLINE_CODE_RE.sub("\x00", text_for_check)
90+
91+
results.append((i, hashes, raw_text, placeholder_text, code_spans))
92+
93+
return results
94+
95+
96+
def restore_code_spans(text, code_spans):
97+
for span in code_spans:
98+
text = text.replace("\x00", span, 1)
99+
return text
100+
101+
102+
def process_file(path, fix=False):
103+
lines = path.read_text(encoding="utf-8").splitlines()
104+
headings = extract_headings(lines)
105+
issues = []
106+
107+
for lineno, hashes, raw_text, placeholder_text, code_spans in headings:
108+
changed, suggestion = check_and_fix_heading(placeholder_text)
109+
if not changed:
110+
continue
111+
suggestion = restore_code_spans(suggestion, code_spans)
112+
issues.append((lineno, hashes, raw_text, suggestion))
113+
114+
if fix:
115+
lines[lineno] = f"{hashes} {suggestion}"
116+
117+
if fix and issues:
118+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
119+
120+
return issues
121+
122+
123+
def main():
124+
parser = argparse.ArgumentParser(description="Check Markdown ATX heading title case")
125+
parser.add_argument("paths", nargs="+")
126+
parser.add_argument("--fix", action="store_true")
127+
args = parser.parse_args()
128+
129+
md_files = []
130+
for p in args.paths:
131+
root = Path(p)
132+
if not root.exists():
133+
continue
134+
md_files.extend(root.rglob("*.md"))
135+
md_files.extend(root.rglob("*.mdx"))
136+
137+
total_issues = 0
138+
for path in sorted(md_files):
139+
issues = process_file(path, fix=args.fix)
140+
for lineno, hashes, raw_text, suggestion in issues:
141+
total_issues += 1
142+
print(f"{path}:{lineno + 1}: {hashes} {raw_text}")
143+
print(f" suggestion -> {hashes} {suggestion}")
144+
145+
if total_issues == 0:
146+
print("All headings pass title case check.")
147+
return 0
148+
149+
print(f"\n{total_issues} heading case issue(s) found.")
150+
if args.fix:
151+
print("Files were auto-fixed. Please review the diff.")
152+
return 0
153+
return 1
154+
155+
156+
if __name__ == "__main__":
157+
sys.exit(main())

0 commit comments

Comments
 (0)