Hands-On: Build a Small Prepublication Check Runner
The main article describes the real site’s checks, including a few that repair files or rebuild output. This lab is much smaller. It creates an invented article and runs three gates against it: required title, local link, and image path. You can see where a failure stops the sequence without fetching project data or running anything against this repository’s posts.
The Python source is the file you run. It needs Python 3.9 or newer and no extra packages. It refuses to use an output path that already exists; choose a fresh directory for each scenario.
Start with a Clean Fixture
From a checkout of this site, run:
python3 html/assets/code/jekyll-site-tooling/post-08a/preflight_lab.py --root /tmp/jekyll-preflight-clean --scenario clean
If you are reading the published article without the repository, download the source file above as preflight_lab.py and run python3 preflight_lab.py with the same options. Choose a different unused output path if the example already exists on your machine.
The runner writes index.md, about.md, and a tiny SVG under the chosen directory. It then reports PASS for front matter, local links, and image paths. The fixture remains there for inspection. There is no Jekyll build in this lab.
Break One Gate at a Time
Use a new output directory for each run:
python3 html/assets/code/jekyll-site-tooling/post-08a/preflight_lab.py --root /tmp/jekyll-preflight-title --scenario missing-title
python3 html/assets/code/jekyll-site-tooling/post-08a/preflight_lab.py --root /tmp/jekyll-preflight-link --scenario broken-link
python3 html/assets/code/jekyll-site-tooling/post-08a/preflight_lab.py --root /tmp/jekyll-preflight-image --scenario missing-image
Each broken run exits with status 1. The missing-title case stops at the first gate. The broken-link case passes front matter and stops at the second. The missing-image case reaches the third. Open the generated index.md files to see exactly what changed. The runner leaves them alone after reporting the failure.
Want to see the shell stop on the first failure too? Run a scenario in an if block and inspect its status:
if python3 html/assets/code/jekyll-site-tooling/post-08a/preflight_lab.py --root /tmp/jekyll-preflight-repeat --scenario broken-link; then
echo "Ready for the next step"
else
echo "Stop and inspect the fixture"
fi
This is not a drop-in replacement for the site’s Ruby short-link checks, Liquid build, HTMLProofer, or GitHub Pages workflow. It shows the smaller idea behind a gate: give each check one clear job, fail visibly, and do not quietly repair the input during an inspection. The real site’s checks are more complicated, and some of them deliberately change files; I review those changes before I decide to publish.
The complete source is here if you want to adapt the exercise:
preflight_lab.py python View source
#!/usr/bin/env python3
"""Small, isolated prepublication gate exercise. Requires Python 3 only."""
import argparse
import re
from pathlib import Path
def make_fixture(root: Path, scenario: str) -> None:
if root.exists():
raise ValueError(f"output already exists: {root}")
root.mkdir(parents=True)
(root / "images").mkdir()
title = "" if scenario == "missing-title" else 'title: "An example article"\n'
target = "missing.md" if scenario == "broken-link" else "about.md"
image = "missing.svg" if scenario == "missing-image" else "cover.svg"
(root / "index.md").write_text(
f"---\n{title}image: /images/{image}\n---\n\n"
f"Read the [about page]({target}).\n",
encoding="utf-8",
)
(root / "about.md").write_text("---\ntitle: About\n---\n\nA second page.\n", encoding="utf-8")
(root / "images" / "cover.svg").write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>\n',
encoding="utf-8",
)
def front_matter(root: Path) -> None:
for page in root.glob("*.md"):
text = page.read_text(encoding="utf-8")
match = re.match(r"\A---\n(.*?)\n---\n", text, re.S)
if not match or not re.search(r"^title:\s*\S", match.group(1), re.M):
raise ValueError(f"{page.name}: missing title in front matter")
def local_links(root: Path) -> None:
for page in root.glob("*.md"):
text = page.read_text(encoding="utf-8")
for target in re.findall(r"\[[^]]+\]\(([^)]+)\)", text):
if target.startswith(("http:", "https:", "#")):
continue
candidate = (page.parent / target).resolve()
if not candidate.is_relative_to(root.resolve()) or not candidate.is_file():
raise ValueError(f"{page.name}: missing local link {target}")
def image_paths(root: Path) -> None:
for page in root.glob("*.md"):
match = re.search(r"^image:\s*/(.+)$", page.read_text(encoding="utf-8"), re.M)
if match and not (root / match.group(1)).is_file():
raise ValueError(f"{page.name}: missing image {match.group(1)}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", required=True, type=Path, help="new output directory; existing paths are refused")
parser.add_argument(
"--scenario",
choices=("clean", "missing-title", "broken-link", "missing-image"),
default="clean",
)
args = parser.parse_args()
try:
make_fixture(args.root, args.scenario)
for name, check in (
("front matter", front_matter),
("local links", local_links),
("image paths", image_paths),
):
print(f"Checking {name}...", flush=True)
check(args.root)
print(f"PASS: {name}", flush=True)
except (OSError, ValueError) as exc:
print(f"STOP: {exc}", flush=True)
return 1
print(f"PASS: all gates; fixture retained at {args.root}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Join the Discussion
Comments for this post live in GitHub Discussions. That keeps moderation in one place and gives the conversation a stable home.