Skip to content

Markdown_lint

FieldValue
TypeSkill Resource
Source~/.copilot/skills/technical-writing/scripts/markdown_lint.py
DescriptionNot specified

Source Content

#!/usr/bin/env python3
"""
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.
"""
import re
import subprocess
import sys
from pathlib import Path
# ── 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() != "---":
return 0, issues
end = 0
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end = i
break
if end == 0:
issues.append((1, "ERROR: Frontmatter opened with '---' but never closed."))
return 0, issues
for i in range(1, end):
lineno = i + 1 # 1-based
ln = lines[i]
if "\t" in ln:
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())
if m:
value = m.group(2).strip()
if ":" in value and not value.startswith('"') and not value.startswith("'") and not value.startswith("{"):
issues.append((
lineno,
f"WARNING (line {lineno}): YAML value '{value}' contains ':' but is not quoted.\n"
f" Consider quoting: \"{value}\""
))
return end, issues
# ── main linting ───────────────────────────────────────────────────────────────
def lint_markdown(content: str, filepath: Path) -> list[str]:
issues: list[str] = []
raw_lines = content.split("\n")
fm_end, fm_issues = parse_frontmatter(raw_lines)
for _, msg in fm_issues:
issues.append(msg)
in_code_block = False
last_heading_level = 0
h1_count = 0
for i, ln in enumerate(raw_lines):
lineno = i + 1
# Skip frontmatter (only when there is one)
if fm_end > 0 and lineno <= fm_end + 1:
continue
# ── code fence tracking ────────────────────────────────────────────────
fence_match = re.match(r'^(`{3,}|~{3,})', ln)
if fence_match:
if not in_code_block:
in_code_block = True
lang = ln.strip()[len(fence_match.group(1)):].strip()
if not lang:
issues.append(
f"ERROR (line {lineno}): Code block has no language hint.\n"
f" Add a language after the backticks — e.g. ```typescript, ```bash, ```json."
)
else:
in_code_block = False
continue
if in_code_block:
continue
# ── headings ───────────────────────────────────────────────────────────
hm = re.match(r'^(#{1,6})\s+(.+)$', ln)
if hm:
level = len(hm.group(1))
text = hm.group(2).strip()
if level == 1:
h1_count += 1
if h1_count > 1:
issues.append(
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:
issues.append(
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:
issues.append(
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'."
)
continue
# ── tables ─────────────────────────────────────────────────────────────
if ln.strip().startswith("|") and ln.strip().endswith("|"):
# Skip separator rows like |---|---|
if re.fullmatch(r'[\|\s\-:]+', ln.strip()):
continue
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
for col_idx, cell in enumerate(cells, 1):
if cell == "":
issues.append(
f"ERROR (line {lineno}, col {col_idx}): Empty table cell.\n"
f" Use '-' as minimum content. Never leave a cell blank."
)
continue
# ── 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()
if alt == "":
issues.append(
f"WARNING (line {lineno}): Empty alt text on {im.group(0)}.\n"
f" Empty alt (![](path)) 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:
issues.append(
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):
url = m.group(1)
issues.append(
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())
if bm:
text = bm.group(1)
issues.append(
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
issues.append(
f"WARNING (line {lineno}): {trailing} trailing space(s)."
)
# ── MDX-specific checks ────────────────────────────────────────────────────
if filepath.suffix == ".mdx":
issues.extend(_lint_mdx(raw_lines, fm_end))
return issues
def _lint_mdx(raw_lines: list[str], fm_end: int) -> list[str]:
issues: list[str] = []
import_block_ended = False
for i, ln in enumerate(raw_lines):
lineno = i + 1
if lineno <= fm_end + 1:
continue
stripped = ln.strip()
# Imports after prose has started
if stripped.startswith("import ") and not stripped.startswith("import type"):
if import_block_ended:
issues.append(
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)
if jsx_component:
name = jsx_component.group(1)
# Ignore standard HTML elements
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:
issues.append(
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."
)
return issues
# ── markdownlint-cli2 (optional) ──────────────────────────────────────────────
def try_markdownlint(filepath: Path) -> list[str]:
"""Run markdownlint-cli2 if available via npx or direct binary."""
issues: list[str] = []
# Determine config path
config_arg = []
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"]):
try:
result = subprocess.run(
cmd + config_arg + [str(filepath)],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
return [] # clean
output = (result.stdout + result.stderr).strip()
if output:
issues.append("── markdownlint-cli2 findings ──")
for line in output.splitlines():
if line.strip():
issues.append(f" {line}")
return issues
except FileNotFoundError:
continue
except subprocess.TimeoutExpired:
issues.append("WARNING (markdownlint-cli2): timed out — skipping.")
return issues
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
if not all_issues:
print(f"✓ {path.name}: No issues found.")
return 0
for issue in all_issues:
print(issue)
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)")
return error_count
def main() -> None:
if len(sys.argv) < 2:
print("Usage: python3 lint.py <file.md|file.mdx> [more files ...]", file=sys.stderr)
sys.exit(1)
total_errors = 0
for arg in sys.argv[1:]:
path = Path(arg)
if not path.exists():
print(f"ERROR: File not found: {path}", file=sys.stderr)
sys.exit(1)
if len(sys.argv) > 2:
print(f"\n{path}")
total_errors += lint_file(path)
sys.exit(1 if total_errors > 0 else 0)
if __name__ == "__main__":
main()