Frontmatter linter for Docs That Teach pages.
Every teaching .md / .mdx page should open with YAML frontmatter carrying the
default fields. This script checks they are present and well-formed.
created ISO date (YYYY-MM-DD)
last_updated ISO date (YYYY-MM-DD), not before `created`
tldr non-empty string (the one-line "why read this")
contributors non-empty list (aliases accepted: contribution, authors)
python3 lint_frontmatter.py <file.md|file.mdx> [more files/dirs ...]
python3 lint_frontmatter.py docs/ # walks for .md/.mdx
python3 lint_frontmatter.py --scaffold # print a ready-to-paste block
Exit codes: 0 = all files clean (errors only), 1 = at least one ERROR.
Warnings are printed but never affect the exit code.
REQUIRED_SCALARS = ("title", "created", "last_updated", "tldr")
REQUIRED_LISTS = ("tags", "contributors")
CONTRIBUTOR_ALIASES = ("contributors", "contribution", "authors")
ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# ── frontmatter extraction ──────────────────────────────────────────────────────
def extract_block(lines):
"""Return the list of frontmatter lines (between the first two '---'), or None."""
if not lines or lines[0].strip() != "---":
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return None # opened but never closed
def get_field(block, key):
Return ('scalar', value, lineno) or ('list', [items], lineno) or None.
Handles `key: value`, `key: [a, b]`, and block lists with `- item` under the key.
for idx, ln in enumerate(block):
m = re.match(rf"^{re.escape(key)}\s*:\s*(.*)$", ln)
rest = m.group(1).strip()
lineno = idx + 2 # +1 for the opening '---', +1 for 1-based
# Inline list: key: [a, b]
if rest.startswith("[") and rest.endswith("]"):
inner = rest[1:-1].strip()
items = [s.strip().strip("\"'") for s in inner.split(",") if s.strip()]
return ("list", items, lineno)
# Block list: key: followed by indented "- item" lines
for j in range(idx + 1, len(block)):
lm = re.match(r"^\s+-\s+(.*)$", block[j])
items.append(lm.group(1).strip().strip("\"'"))
elif block[j].strip() == "":
return ("list", items, lineno)
return ("scalar", "", lineno) # empty value
return ("scalar", rest.strip().strip("\"'"), lineno)
# ── linting ──────────────────────────────────────────────────────────────────────
"""Return a list of (level, message) tuples for one file."""
content = path.read_text(encoding="utf-8")
block = extract_block(content.split("\n"))
issues.append(("ERROR", "No YAML frontmatter found (must open with '---' and close with '---')."))
for key in REQUIRED_SCALARS:
field = get_field(block, key)
issues.append(("ERROR", f"Missing required field: `{key}`."))
kind, value, lineno = field
if kind != "scalar" or not value:
issues.append(("ERROR", f"`{key}` (line {lineno}) is empty — give it a value."))
if key in ("created", "last_updated") and not ISO_DATE_RE.match(value):
issues.append(("ERROR", f"`{key}` (line {lineno}) = '{value}' is not an ISO date (YYYY-MM-DD)."))
if key == "tldr" and len(value) > TLDR_MAX:
issues.append(("WARNING", f"`tldr` (line {lineno}) is {len(value)} chars — keep it to one line (≤ {TLDR_MAX})."))
# created vs last_updated ordering
created = get_field(block, "created")
updated = get_field(block, "last_updated")
if created and updated and created[0] == "scalar" and updated[0] == "scalar":
if ISO_DATE_RE.match(created[1]) and ISO_DATE_RE.match(updated[1]) and updated[1] < created[1]:
issues.append(("ERROR", f"`last_updated` ({updated[1]}) is before `created` ({created[1]})."))
tags = get_field(block, "tags")
issues.append(("ERROR", "Missing required field: `tags`."))
elif tags[0] != "list" or not tags[1]:
issues.append(("ERROR", f"`tags` (line {tags[2]}) must be a non-empty list — e.g. `tags: [ci, platform]`."))
# contributors (list, with accepted aliases)
for alias in CONTRIBUTOR_ALIASES:
contrib = get_field(block, alias)
issues.append(("ERROR", "Missing required field: `contributors` (a non-empty list of people)."))
if used_alias != "contributors":
issues.append(("WARNING", f"Field `{used_alias}` — prefer `contributors` for consistency."))
if contrib[0] != "list" or not contrib[1]:
issues.append(("ERROR", f"`{used_alias}` (line {contrib[2]}) must be a non-empty list — e.g. `contributors: [Jane Doe]`."))
title: {{Specific, searchable title — answers a real question}}
tldr: One sentence on what the reader learns and why it is worth their time.
contributors: [Your Name]
# ── file discovery ───────────────────────────────────────────────────────────────
files.extend(sorted(path.rglob("*.md")))
files.extend(sorted(path.rglob("*.mdx")))
print(f"ERROR: not found: {p}", file=sys.stderr)
if args[0] == "--scaffold":
errors = [m for lvl, m in issues if lvl == "ERROR"]
warnings = [m for lvl, m in issues if lvl == "WARNING"]
total_errors += len(errors)
print(f"✓ {path}: frontmatter complete.")
print(f"\nSummary: {total_errors} error(s) across {len(files)} file(s).")
sys.exit(1 if total_errors else 0)
if __name__ == "__main__":