Markdown / MDX linter for project-specific rules.
Usage: python3 lint.py <file.md|file.mdx>
Exits 0 if clean (errors only), 1 if errors found.
Warnings do not affect the exit code.
# ── helpers ────────────────────────────────────────────────────────────────────
INTRO_HEADINGS = frozenset([
"introduction", "overview", "summary", "preface",
"foreword", "background", "about",
BARE_URL_RE = re.compile(
r'(?<!\()' # not preceded by ( → not part of [text](url)
r'(?<!\]:\s)' # not a reference-style link definition
r'(?<!<)' # not an angle-bracket link
r'(https?://[^\s\)\]>,"\']+)' # the URL itself
BOLD_ONLY_LINE_RE = re.compile(r'^\*\*([^*]+)\*\*:?\s*$')
IMAGE_RE = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
# Alt text that names the medium instead of the content — "screenshot"
# tells a screen-reader user nothing about what's in the image.
BOILERPLATE_ALT = frozenset({
"image", "picture", "photo", "photograph", "screenshot",
"graphic", "icon", "img", "diagram", "chart",
MARKDOWNLINT_CONFIG = Path(__file__).parent / "markdownlint.json"
# ── frontmatter ────────────────────────────────────────────────────────────────
def parse_frontmatter(lines: list[str]) -> tuple[int, list[tuple[int, str]]]:
"""Return (frontmatter_end_line_index, [(lineno, issue), ...])."""
issues: list[tuple[int, str]] = []
if not lines or lines[0].strip() != "---":
for i in range(1, len(lines)):
if lines[i].strip() == "---":
issues.append((1, "ERROR: Frontmatter opened with '---' but never closed."))
issues.append((lineno, f"ERROR (line {lineno}): Tab character in YAML frontmatter — use spaces only."))
# Unquoted strings containing : that aren't key: value patterns
# key: value is fine; key: value: extra is ambiguous
m = re.match(r'^([A-Za-z_][A-Za-z0-9_]*):\s*(.+)$', ln.strip())
value = m.group(2).strip()
if ":" in value and not value.startswith('"') and not value.startswith("'") and not value.startswith("{"):
f"WARNING (line {lineno}): YAML value '{value}' contains ':' but is not quoted.\n"
f" Consider quoting: \"{value}\""
# ── main linting ───────────────────────────────────────────────────────────────
def lint_markdown(content: str, filepath: Path) -> list[str]:
raw_lines = content.split("\n")
fm_end, fm_issues = parse_frontmatter(raw_lines)
for i, ln in enumerate(raw_lines):
# Skip frontmatter (only when there is one)
if fm_end > 0 and lineno <= fm_end + 1:
# ── code fence tracking ────────────────────────────────────────────────
fence_match = re.match(r'^(`{3,}|~{3,})', ln)
lang = ln.strip()[len(fence_match.group(1)):].strip()
f"ERROR (line {lineno}): Code block has no language hint.\n"
f" Add a language after the backticks — e.g. ```typescript, ```bash, ```json."
# ── headings ───────────────────────────────────────────────────────────
hm = re.match(r'^(#{1,6})\s+(.+)$', ln)
text = hm.group(2).strip()
f"ERROR (line {lineno}): Second H1 heading found: '{text}'.\n"
f" A document should have exactly one H1 (the title)."
if last_heading_level > 0 and level > last_heading_level + 1:
f"ERROR (line {lineno}): Heading level jumps from H{last_heading_level} to H{level}.\n"
f" Use H{last_heading_level + 1} next — don't skip levels."
last_heading_level = level
if text.lower().rstrip(":") in INTRO_HEADINGS:
f"WARNING (line {lineno}): Section-intro heading '{text}'.\n"
f" Remove it — the heading should describe the content, not just say 'this is the intro'."
# ── tables ─────────────────────────────────────────────────────────────
if ln.strip().startswith("|") and ln.strip().endswith("|"):
# Skip separator rows like |---|---|
if re.fullmatch(r'[\|\s\-:]+', ln.strip()):
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
for col_idx, cell in enumerate(cells, 1):
f"ERROR (line {lineno}, col {col_idx}): Empty table cell.\n"
f" Use '-' as minimum content. Never leave a cell blank."
# ── image alt text ────────────────────────────────────────────────────
# Strip inline code first — a doc showing `![]()` as example syntax
# isn't a real image reference and shouldn't be checked as one.
line_no_code = re.sub(r'`[^`\n]+`', '', ln)
for im in IMAGE_RE.finditer(line_no_code):
alt = im.group(1).strip()
f"WARNING (line {lineno}): Empty alt text on {im.group(0)}.\n"
f" Empty alt () is allowed for a genuinely decorative image —\n"
f" confirm that's the case here. Otherwise describe what the image shows."
elif alt.lower() in BOILERPLATE_ALT:
f"WARNING (line {lineno}): Alt text '{alt}' names the medium, not the content.\n"
f" Describe what's actually in the image — '{alt}' tells a screen-reader user nothing."
# ── bare URLs ──────────────────────────────────────────────────────────
# Strip markdown links first so we don't false-positive on the URL part of [text](url)
line_no_links = re.sub(r'\[[^\]]+\]\([^\)]+\)', '', ln)
line_no_links = re.sub(r'<https?://[^>]+>', '', line_no_links)
for m in BARE_URL_RE.finditer(line_no_links):
f"WARNING (line {lineno}): Bare URL in prose: {url[:70]}{'...' if len(url) > 70 else ''}\n"
f" Use [descriptive text]({url}) so link text makes sense out of context."
# ── bold-as-heading ────────────────────────────────────────────────────
bm = BOLD_ONLY_LINE_RE.match(ln.strip())
f"WARNING (line {lineno}): Standalone bold '{text}' looks like a heading in disguise.\n"
f" If it introduces a section or named concept, use #### {text} instead."
# ── trailing spaces ────────────────────────────────────────────────────
if ln.rstrip("\n") != ln.rstrip():
trailing = len(ln.rstrip("\n")) - len(ln.rstrip())
if trailing > 2: # 2 trailing spaces is intentional line break, >2 is likely a mistake
f"WARNING (line {lineno}): {trailing} trailing space(s)."
# ── MDX-specific checks ────────────────────────────────────────────────────
if filepath.suffix == ".mdx":
issues.extend(_lint_mdx(raw_lines, fm_end))
def _lint_mdx(raw_lines: list[str], fm_end: int) -> list[str]:
import_block_ended = False
for i, ln in enumerate(raw_lines):
# Imports after prose has started
if stripped.startswith("import ") and not stripped.startswith("import type"):
f"WARNING (line {lineno}): 'import' statement after prose has started.\n"
f" Move all imports to the top of the file, right after frontmatter."
elif stripped and not stripped.startswith("import") and not stripped.startswith("export"):
import_block_ended = True
# Lowercase JSX component names
jsx_component = re.search(r'<([a-z][A-Za-z0-9]*)[\s/>]', stripped)
name = jsx_component.group(1)
# Ignore standard HTML elements
"a", "b", "br", "code", "div", "em", "h1", "h2", "h3", "h4",
"h5", "h6", "hr", "i", "img", "input", "li", "ol", "p", "pre",
"section", "span", "strong", "table", "td", "th", "tr", "ul",
"article", "aside", "details", "figure", "figcaption", "footer",
"header", "main", "nav", "summary",
if name not in html_elements and len(name) > 2:
f"WARNING (line {lineno}): Component <{name}> uses lowercase — MDX components must be PascalCase.\n"
f" Rename to <{name[0].upper() + name[1:]}> or use a standard HTML element."
# ── markdownlint-cli2 (optional) ──────────────────────────────────────────────
def try_markdownlint(filepath: Path) -> list[str]:
"""Run markdownlint-cli2 if available via npx or direct binary."""
if MARKDOWNLINT_CONFIG.exists():
config_arg = ["--config", str(MARKDOWNLINT_CONFIG)]
# Try markdownlint-cli2 directly, then via npx
for cmd in (["markdownlint-cli2"], ["npx", "--yes", "markdownlint-cli2"]):
cmd + config_arg + [str(filepath)],
capture_output=True, text=True, timeout=30,
if result.returncode == 0:
output = (result.stdout + result.stderr).strip()
issues.append("── markdownlint-cli2 findings ──")
for line in output.splitlines():
issues.append(f" {line}")
except FileNotFoundError:
except subprocess.TimeoutExpired:
issues.append("WARNING (markdownlint-cli2): timed out — skipping.")
return [] # not installed, silently skip
# ── main ────────────────────────────────────────────────────────────────────────
def lint_file(path: Path) -> int:
"""Lint one file and print its report. Returns the number of errors."""
content = path.read_text(encoding="utf-8")
issues = lint_markdown(content, path)
ml_issues = try_markdownlint(path)
all_issues = issues + ml_issues
print(f"✓ {path.name}: No issues found.")
error_count = sum(1 for i in issues if i.startswith("ERROR"))
warning_count = sum(1 for i in issues if i.startswith("WARNING"))
print(f"\nSummary: {error_count} error(s), {warning_count} warning(s)")
print("Usage: python3 lint.py <file.md|file.mdx> [more files ...]", file=sys.stderr)
print(f"ERROR: File not found: {path}", file=sys.stderr)
total_errors += lint_file(path)
sys.exit(1 if total_errors > 0 else 0)
if __name__ == "__main__":