"""Structural linter for Docs That Teach MDX pages.
Checks a single .mdx (or .md) deliverable against three structural rules
that the SKILL.md's print-friendly and visual-learning gates depend on:
(a) No top-level heading section's ENTIRE body lives inside one
Tabs/Accordion block — print collapses both, so essential content
must have standalone prose outside them.
(b) Every visual (markdown image, <Figure>/<Screenshot>/<Diagram>-style
JSX, or a ```mermaid fenced block) has a "what to notice" line
within a few lines after it.
(c) Every PascalCase JSX component tag used in the file appears in an
optional --allowlist file. Skipped (with a note) if no allowlist
This is a structural/heuristic check, not a full MDX parser — it uses
regex over lines, which is deliberately simple and matches the mechanical
nature of the rules it enforces (same spirit as markdown_lint.py in the
technical-writing skill).
lint_docs_that_teach.py FILE.mdx [--allowlist path/to/allowlist.txt]
Exit 0 = pass. Exit 1 = violations found. Exit 2 = usage/file error.
WHAT_TO_NOTICE_RE = re.compile(r"what to notice", re.IGNORECASE)
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
IMAGE_RE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
VISUAL_JSX_RE = re.compile(r"<(Figure|Screenshot|Diagram)\b")
MERMAID_FENCE_RE = re.compile(r"^```mermaid\s*$")
FENCE_RE = re.compile(r"^```")
JSX_OPEN_TAG_RE = re.compile(r"<([A-Z][A-Za-z0-9]*)\b")
JSX_CLOSE_TAG_RE = re.compile(r"</([A-Z][A-Za-z0-9]*)\s*>")
JSX_SELF_CLOSE_RE = re.compile(r"/>")
HIDING_COMPONENTS = ("Tabs", "Accordion")
# Built-in tags every MDX/JSX file may use without an allowlist entry —
# not design-system components, so they should never trip check (c).
BUILTIN_JSX_ALLOW = {"Fragment"}
WHAT_TO_NOTICE_WINDOW = 4 # lines to look ahead after a visual for the line
def read_lines(path: Path) -> list[str]:
return path.read_text(encoding="utf-8").splitlines()
def strip_code_fences(lines: list[str]) -> list[bool]:
"""Return a same-length list marking which lines are inside a non-mermaid
fenced code block, so we don't misread example code as real JSX/headings.
Mermaid fences are NOT masked here — check (b) needs to see them."""
if FENCE_RE.match(stripped):
fence_is_mermaid = bool(MERMAID_FENCE_RE.match(stripped))
mask.append(fence_is_mermaid) # the ```mermaid line itself
mask.append(fence_is_mermaid) # the closing ``` line
def check_hidden_sections(lines: list[str], in_code: list[bool]) -> list[str]:
"""Check (a): a heading section whose entire non-blank body sits inside
one Tabs/Accordion block has no standalone prose and fails print."""
# Build list of (line_idx, level, title) for real headings (not in code).
for i, line in enumerate(lines):
m = HEADING_RE.match(line)
headings.append((i, len(m.group(1)), m.group(2)))
for idx, (start, level, title) in enumerate(headings):
# Section body ends right before the next heading of equal-or-higher
# level (lower or equal number of #'s), or EOF.
for j in range(idx + 1, len(headings)):
other_start, other_level, _ = headings[j]
(i, lines[i]) for i in range(start + 1, end)
if lines[i].strip() and not in_code[i]
continue # empty section — nothing to hide
# Find spans of Tabs/Accordion blocks within the body's line range.
hiding_spans = find_hiding_spans(lines, start + 1, end)
# Does every body line fall inside some hiding span?
def covered(i: int) -> bool:
return any(s <= i <= e for s, e in hiding_spans)
if all(covered(i) for i, _ in body):
f"{{file}}:{start + 1}: heading '{title}' — entire section body "
f"is inside a Tabs/Accordion block (lines {hiding_spans[0][0] + 1}-"
f"{hiding_spans[-1][1] + 1}); no standalone prose survives print"
def find_hiding_spans(lines: list[str], start: int, end: int) -> list[tuple[int, int]]:
"""Find (start_idx, end_idx) spans of top-level Tabs/Accordion blocks
within lines[start:end], tracking nested same-name tags by depth."""
open_stack = [] # (tag_name, open_line_idx)
for i in range(start, end):
for tag in HIDING_COMPONENTS:
for _ in re.finditer(rf"<{tag}\b[^>]*?(/?)>", line):
pass # handled below via generic scan
# Generic scan preserving order of open/close on the line.
for m in re.finditer(r"</?([A-Z][A-Za-z0-9]*)\b[^>]*?(/?)>", line):
self_closing = m.group(2) == "/" or line[m.start():m.end()].endswith("/>")
is_close = line[m.start():m.start() + 2] == "</"
if tag not in HIDING_COMPONENTS:
if open_stack and open_stack[-1][0] == tag:
_, open_i = open_stack.pop()
spans.append((open_i, i))
continue # self-closing Tabs/Accordion has no body
open_stack.append((tag, i))
def check_visual_captions(lines: list[str], in_code: list[bool]) -> list[str]:
"""Check (b): every visual has a nearby 'what to notice' line."""
for i, line in enumerate(lines):
if not in_code[i] and IMAGE_RE.search(line):
is_visual, label = True, "image"
elif not in_code[i] and VISUAL_JSX_RE.search(line):
m = VISUAL_JSX_RE.search(line)
is_visual, label = True, f"<{m.group(1)}>"
elif in_code[i] and MERMAID_FENCE_RE.match(line.strip()):
is_visual, label = True, "mermaid diagram"
# For mermaid, look after the closing fence, not mid-diagram.
if label == "mermaid diagram":
while j < n and not FENCE_RE.match(lines[j].strip()):
window = lines[search_start:search_start + WHAT_TO_NOTICE_WINDOW]
if not any(WHAT_TO_NOTICE_WINDOW and WHAT_TO_NOTICE_RE.search(w) for w in window):
f"{{file}}:{i + 1}: {label} has no \"what to notice\" line "
f"within {WHAT_TO_NOTICE_WINDOW} lines after it"
def check_allowlist(lines: list[str], in_code: list[bool], allowlist_path: Path | None):
"""Check (c): every PascalCase JSX component used is in the allowlist."""
if allowlist_path is None:
return [], True # (violations, ran)
if not allowlist_path.is_file():
print(f"ERROR: allowlist file not found: {allowlist_path}", file=sys.stderr)
for raw in allowlist_path.read_text(encoding="utf-8").splitlines():
if not name or name.startswith("#"):
allowed |= BUILTIN_JSX_ALLOW
seen_at: dict[str, int] = {}
for i, line in enumerate(lines):
for m in JSX_OPEN_TAG_RE.finditer(line):
if tag not in allowed and tag not in seen_at:
for tag, lineno in sorted(seen_at.items(), key=lambda kv: kv[1]):
f"{{file}}:{lineno}: component <{tag}> is not in the allowlist "
ap = argparse.ArgumentParser(
description="Lint a Docs That Teach .mdx page for print-safety, "
"visual captions, and (optionally) an allowed component list.")
ap.add_argument("file", help="path to the .mdx (or .md) file to lint")
ap.add_argument("--allowlist", help="path to a text file of allowed PascalCase "
"JSX component names, one per line")
print(f"ERROR: file not found: {path}", file=sys.stderr)
in_code = strip_code_fences(lines)
all_violations: list[str] = []
all_violations += check_hidden_sections(lines, in_code)
all_violations += check_visual_captions(lines, in_code)
allowlist_path = Path(args.allowlist) if args.allowlist else None
allowlist_violations, ran = check_allowlist(lines, in_code, allowlist_path)
all_violations += allowlist_violations
if allowlist_path is None:
print("NOTE: --allowlist not given — skipping component-allowlist check (c).")
print(f"OK: {path} — no violations found.")
print(f"\n{len(all_violations)} violation(s) in {path}:\n")
print(" " + v.format(file=path))
if __name__ == "__main__":