| name | safe-markdown-auto-fix |
| description | Auto-fix Markdown prose without corrupting code blocks, inline code, URLs, or HTML. |
| tags | ["markdown","regex","tooling"] |
Safe Markdown auto-fix
When to use
You're writing (or reviewing) a regex-based fixer for Markdown —
spacing rules, typography, terminology normalization. Applies to any
lint-and-fix tool, including the cndocstyle package shipped with
cn-doc-style-guide.
Problem
A naive re.sub over the whole file will cheerfully mangle:
- Fenced code blocks (
): inserting "helpful" spaces in
the middle of a Python string breaks the program.
- Inline code spans (
`foo_bar`): adding space around _ ruins
identifiers.
- Link URLs (
[text](https://…)): even reading URLs as prose
produces false positives like "space between CJK and ASCII" inside
a percent-encoded path.
- HTML tags / comments:
<img alt="你好World"> is prose inside HTML,
but the tag itself must stay intact.
Solution
Always tokenize the file into "protected" and "prose" regions before
applying any substitution. A minimal protection list:
PROTECT = [
re.compile(r"```[\s\S]*?```", re.MULTILINE),
re.compile(r"(?:^|\n)(?: {4,}|\t).+", re.MULTILINE),
re.compile(r"`[^`\n]+`"),
re.compile(r"\]\([^)\n]+\)"),
re.compile(r"<!--[\s\S]*?-->"),
re.compile(r"<[a-zA-Z/][^>]*>"),
]
Then:
- Walk the text, carving out every match into a
placeholder
(e.g. \x00PROTECT_N\x00) and storing the original.
- Run your prose-level substitutions on the placeholder-laced text.
- Restore placeholders.
For a fixer it's also important to:
- Default to dry-run. Print a unified diff; require
--apply to
write.
- Be idempotent. Running the tool twice must not change output.
- Fail closed. If in doubt (ambiguous context, mixed-script
identifiers), leave the text untouched and log a warning rather
than silently "correcting" it.
Example
Wrong — direct substitution corrupts code:
new = re.sub(r"([\u4e00-\u9fff])([A-Za-z0-9])", r"\1 \2", text)
Right — protect first, then substitute:
protected, restore = protect(text, PROTECT)
protected = re.sub(r"([\u4e00-\u9fff])([A-Za-z0-9])", r"\1 \2", protected)
new = restore(protected)
Pitfalls
- Fenced code blocks can be indented inside list items; anchor your
regex to
``` rather than "start of line".
- Backslash-escaped backticks inside inline code (
\`) are rare
but real — include a unit test.
- Don't touch line endings or trailing whitespace as part of the same
pass; that's a separate tool (or a separate regex with clear
opt-in).
See also