Skip to content

Commit 4f2aa1a

Browse files
committed
w
1 parent 8fb30c9 commit 4f2aa1a

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

reformat_markdown.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
#!/usr/bin/env python3
2+
"""Conservative Markdown reformatter for prose-heavy notes.
3+
4+
This script is designed for small writing repos where readability matters more
5+
than strict Markdown tooling conventions. It:
6+
7+
- trims trailing whitespace
8+
- normalizes excessive blank lines
9+
- keeps one blank line around headings and thematic breaks
10+
- reflows plain prose paragraphs to a target width
11+
- preserves fenced code blocks, block quotes, and list items as written
12+
13+
Usage:
14+
python3 reformat_markdown.py
15+
python3 reformat_markdown.py --check
16+
python3 reformat_markdown.py --width 72 docs notes
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import argparse
22+
import re
23+
import sys
24+
import textwrap
25+
from pathlib import Path
26+
27+
28+
DEFAULT_WIDTH = 88
29+
MARKDOWN_SUFFIXES = {".md", ".markdown", ".mdown"}
30+
31+
32+
def is_markdown_file(path: Path) -> bool:
33+
return path.is_file() and path.suffix.lower() in MARKDOWN_SUFFIXES
34+
35+
36+
def iter_markdown_files(paths: list[Path]) -> list[Path]:
37+
files: list[Path] = []
38+
for path in paths:
39+
if path.is_file():
40+
if is_markdown_file(path):
41+
files.append(path)
42+
continue
43+
if path.is_dir():
44+
for candidate in sorted(path.rglob("*")):
45+
if is_markdown_file(candidate):
46+
files.append(candidate)
47+
deduped = sorted(dict.fromkeys(files))
48+
return deduped
49+
50+
51+
def is_fence(line: str) -> bool:
52+
stripped = line.lstrip()
53+
return stripped.startswith("```") or stripped.startswith("~~~")
54+
55+
56+
def is_heading(line: str) -> bool:
57+
return bool(re.match(r"^\s{0,3}#{1,6}\s+\S", line))
58+
59+
60+
def is_thematic_break(line: str) -> bool:
61+
stripped = line.strip()
62+
return stripped in {"---", "***", "___"}
63+
64+
65+
def is_list_item(line: str) -> bool:
66+
return bool(re.match(r"^\s*(?:[-+*]|\d+[.)])\s+\S", line))
67+
68+
69+
def is_block_quote(line: str) -> bool:
70+
return bool(re.match(r"^\s*>\s?", line))
71+
72+
73+
def flush_paragraph(buffer: list[str], width: int, out: list[str]) -> None:
74+
if not buffer:
75+
return
76+
text = " ".join(part.strip() for part in buffer if part.strip())
77+
if not text:
78+
buffer.clear()
79+
return
80+
wrapped = textwrap.fill(
81+
text,
82+
width=width,
83+
break_long_words=False,
84+
break_on_hyphens=False,
85+
)
86+
out.extend(wrapped.splitlines())
87+
buffer.clear()
88+
89+
90+
def normalize_blank_lines(lines: list[str]) -> list[str]:
91+
normalized: list[str] = []
92+
blank_run = 0
93+
for line in lines:
94+
if line.strip():
95+
blank_run = 0
96+
normalized.append(line.rstrip())
97+
continue
98+
blank_run += 1
99+
if blank_run <= 1:
100+
normalized.append("")
101+
while normalized and normalized[-1] == "":
102+
normalized.pop()
103+
return normalized
104+
105+
106+
def ensure_spacing(lines: list[str]) -> list[str]:
107+
out: list[str] = []
108+
for line in lines:
109+
special = is_heading(line) or is_thematic_break(line)
110+
if special and out and out[-1] != "":
111+
out.append("")
112+
out.append(line)
113+
if special:
114+
out.append("")
115+
collapsed: list[str] = []
116+
blank_run = 0
117+
for line in out:
118+
if line == "":
119+
blank_run += 1
120+
if blank_run <= 1:
121+
collapsed.append(line)
122+
else:
123+
blank_run = 0
124+
collapsed.append(line)
125+
while collapsed and collapsed[-1] == "":
126+
collapsed.pop()
127+
return collapsed
128+
129+
130+
def format_markdown(text: str, width: int) -> str:
131+
source_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
132+
source_lines = normalize_blank_lines(source_lines)
133+
134+
out: list[str] = []
135+
paragraph: list[str] = []
136+
in_fence = False
137+
138+
for line in source_lines:
139+
stripped = line.strip()
140+
141+
if is_fence(line):
142+
flush_paragraph(paragraph, width, out)
143+
out.append(line.rstrip())
144+
in_fence = not in_fence
145+
continue
146+
147+
if in_fence:
148+
out.append(line.rstrip())
149+
continue
150+
151+
if not stripped:
152+
flush_paragraph(paragraph, width, out)
153+
out.append("")
154+
continue
155+
156+
if (
157+
is_heading(line)
158+
or is_thematic_break(line)
159+
or is_list_item(line)
160+
or is_block_quote(line)
161+
):
162+
flush_paragraph(paragraph, width, out)
163+
out.append(line.rstrip())
164+
continue
165+
166+
paragraph.append(line)
167+
168+
flush_paragraph(paragraph, width, out)
169+
out = ensure_spacing(normalize_blank_lines(out))
170+
return "\n".join(out) + "\n"
171+
172+
173+
def process_file(path: Path, width: int, check_only: bool) -> bool:
174+
original = path.read_text(encoding="utf-8")
175+
formatted = format_markdown(original, width)
176+
changed = formatted != original
177+
if changed and not check_only:
178+
path.write_text(formatted, encoding="utf-8")
179+
return changed
180+
181+
182+
def parse_args() -> argparse.Namespace:
183+
parser = argparse.ArgumentParser(description=__doc__)
184+
parser.add_argument(
185+
"paths",
186+
nargs="*",
187+
default=["."],
188+
help="Markdown files or directories to process.",
189+
)
190+
parser.add_argument(
191+
"--width",
192+
type=int,
193+
default=DEFAULT_WIDTH,
194+
help=f"Wrap prose paragraphs to this width. Default: {DEFAULT_WIDTH}.",
195+
)
196+
parser.add_argument(
197+
"--check",
198+
action="store_true",
199+
help="Report files that would change without rewriting them.",
200+
)
201+
return parser.parse_args()
202+
203+
204+
def main() -> int:
205+
args = parse_args()
206+
paths = [Path(item) for item in args.paths]
207+
files = iter_markdown_files(paths)
208+
209+
if not files:
210+
print("No Markdown files found.", file=sys.stderr)
211+
return 1
212+
213+
changed_files: list[Path] = []
214+
for path in files:
215+
if process_file(path, width=args.width, check_only=args.check):
216+
changed_files.append(path)
217+
218+
if args.check:
219+
if changed_files:
220+
for path in changed_files:
221+
print(path)
222+
print(f"{len(changed_files)} file(s) would be reformatted.")
223+
return 1
224+
print("All Markdown files already match the formatter.")
225+
return 0
226+
227+
for path in changed_files:
228+
print(f"Reformatted {path}")
229+
if not changed_files:
230+
print("No changes needed.")
231+
return 0
232+
233+
234+
if __name__ == "__main__":
235+
raise SystemExit(main())

0 commit comments

Comments
 (0)