Skip to content

Lint_docs_that_teach_frontmatter

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

Source Content

#!/usr/bin/env python3
"""
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.
Required fields:
title non-empty string
created ISO date (YYYY-MM-DD)
last_updated ISO date (YYYY-MM-DD), not before `created`
tags non-empty list
tldr non-empty string (the one-line "why read this")
contributors non-empty list (aliases accepted: contribution, authors)
Usage:
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.
"""
import re
import sys
from pathlib import Path
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}$")
TLDR_MAX = 240
# ── 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() != "---":
return None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return lines[1:i]
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)
if not m:
continue
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
if rest == "":
items = []
for j in range(idx + 1, len(block)):
lm = re.match(r"^\s+-\s+(.*)$", block[j])
if lm:
items.append(lm.group(1).strip().strip("\"'"))
elif block[j].strip() == "":
continue
else:
break
if items:
return ("list", items, lineno)
return ("scalar", "", lineno) # empty value
return ("scalar", rest.strip().strip("\"'"), lineno)
return None
# ── linting ──────────────────────────────────────────────────────────────────────
def lint_file(path):
"""Return a list of (level, message) tuples for one file."""
issues = []
content = path.read_text(encoding="utf-8")
block = extract_block(content.split("\n"))
if block is None:
issues.append(("ERROR", "No YAML frontmatter found (must open with '---' and close with '---')."))
return issues
# Required scalars
for key in REQUIRED_SCALARS:
field = get_field(block, key)
if field is None:
issues.append(("ERROR", f"Missing required field: `{key}`."))
continue
kind, value, lineno = field
if kind != "scalar" or not value:
issues.append(("ERROR", f"`{key}` (line {lineno}) is empty — give it a value."))
continue
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 (list)
tags = get_field(block, "tags")
if tags is None:
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)
contrib = None
used_alias = None
for alias in CONTRIBUTOR_ALIASES:
contrib = get_field(block, alias)
if contrib is not None:
used_alias = alias
break
if contrib is None:
issues.append(("ERROR", "Missing required field: `contributors` (a non-empty list of people)."))
else:
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]`."))
return issues
SCAFFOLD = """---
title: {{Specific, searchable title — answers a real question}}
created: YYYY-MM-DD
last_updated: YYYY-MM-DD
status: draft
tags: [area, topic]
tldr: One sentence on what the reader learns and why it is worth their time.
contributors: [Your Name]
---"""
# ── file discovery ───────────────────────────────────────────────────────────────
def collect(paths):
files = []
for p in paths:
path = Path(p)
if path.is_dir():
files.extend(sorted(path.rglob("*.md")))
files.extend(sorted(path.rglob("*.mdx")))
elif path.exists():
files.append(path)
else:
print(f"ERROR: not found: {p}", file=sys.stderr)
return files
def main():
args = sys.argv[1:]
if not args:
print(__doc__.strip())
sys.exit(1)
if args[0] == "--scaffold":
print(SCAFFOLD)
sys.exit(0)
files = collect(args)
if not files:
sys.exit(1)
total_errors = 0
for path in files:
issues = lint_file(path)
errors = [m for lvl, m in issues if lvl == "ERROR"]
warnings = [m for lvl, m in issues if lvl == "WARNING"]
total_errors += len(errors)
if not issues:
print(f"✓ {path}: frontmatter complete.")
continue
print(f"✗ {path}")
for lvl, msg in issues:
print(f" {lvl}: {msg}")
print(f"\nSummary: {total_errors} error(s) across {len(files)} file(s).")
sys.exit(1 if total_errors else 0)
if __name__ == "__main__":
main()