Skip to content

Lint_docs_that_teach

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

Source Content

#!/usr/bin/env python3
"""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
is given.
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).
Usage:
lint_docs_that_teach.py FILE.mdx [--allowlist path/to/allowlist.txt]
Exit 0 = pass. Exit 1 = violations found. Exit 2 = usage/file error.
"""
import argparse
import re
import sys
from pathlib import Path
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."""
in_fence = False
fence_is_mermaid = False
mask = []
for line in lines:
stripped = line.strip()
if FENCE_RE.match(stripped):
if not in_fence:
in_fence = True
fence_is_mermaid = bool(MERMAID_FENCE_RE.match(stripped))
mask.append(fence_is_mermaid) # the ```mermaid line itself
else:
mask.append(fence_is_mermaid) # the closing ``` line
in_fence = False
fence_is_mermaid = False
continue
mask.append(in_fence)
return mask
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."""
violations = []
# Build list of (line_idx, level, title) for real headings (not in code).
headings = []
for i, line in enumerate(lines):
if in_code[i]:
continue
m = HEADING_RE.match(line)
if m:
headings.append((i, len(m.group(1)), m.group(2)))
if not headings:
return violations
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.
end = len(lines)
for j in range(idx + 1, len(headings)):
other_start, other_level, _ = headings[j]
if other_level <= level:
end = other_start
break
body = [
(i, lines[i]) for i in range(start + 1, end)
if lines[i].strip() and not in_code[i]
]
if not body:
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)
if not hiding_spans:
continue
# 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):
violations.append(
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"
)
return violations
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."""
spans = []
open_stack = [] # (tag_name, open_line_idx)
for i in range(start, end):
line = lines[i]
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):
tag = m.group(1)
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:
continue
if is_close:
if open_stack and open_stack[-1][0] == tag:
_, open_i = open_stack.pop()
if not open_stack:
spans.append((open_i, i))
elif self_closing:
continue # self-closing Tabs/Accordion has no body
else:
open_stack.append((tag, i))
return spans
def check_visual_captions(lines: list[str], in_code: list[bool]) -> list[str]:
"""Check (b): every visual has a nearby 'what to notice' line."""
violations = []
n = len(lines)
for i, line in enumerate(lines):
is_visual = False
label = None
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"
if not is_visual:
continue
# For mermaid, look after the closing fence, not mid-diagram.
search_start = i + 1
if label == "mermaid diagram":
j = i + 1
while j < n and not FENCE_RE.match(lines[j].strip()):
j += 1
search_start = j + 1
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):
violations.append(
f"{{file}}:{i + 1}: {label} has no \"what to notice\" line "
f"within {WHAT_TO_NOTICE_WINDOW} lines after it"
)
return violations
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)
sys.exit(2)
allowed = set()
for raw in allowlist_path.read_text(encoding="utf-8").splitlines():
name = raw.strip()
if not name or name.startswith("#"):
continue
allowed.add(name)
allowed |= BUILTIN_JSX_ALLOW
violations = []
seen_at: dict[str, int] = {}
for i, line in enumerate(lines):
if in_code[i]:
continue
for m in JSX_OPEN_TAG_RE.finditer(line):
tag = m.group(1)
if tag not in allowed and tag not in seen_at:
seen_at[tag] = i + 1
for tag, lineno in sorted(seen_at.items(), key=lambda kv: kv[1]):
violations.append(
f"{{file}}:{lineno}: component <{tag}> is not in the allowlist "
f"({allowlist_path})"
)
return violations, True
def main() -> None:
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")
args = ap.parse_args()
path = Path(args.file)
if not path.is_file():
print(f"ERROR: file not found: {path}", file=sys.stderr)
sys.exit(2)
lines = read_lines(path)
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).")
if not all_violations:
print(f"OK: {path} — no violations found.")
sys.exit(0)
print(f"\n{len(all_violations)} violation(s) in {path}:\n")
for v in all_violations:
print(" " + v.format(file=path))
sys.exit(1)
if __name__ == "__main__":
main()