Skip to content

Commit 2889bed

Browse files
authored
Merge pull request #109 from makeabilitylab/chore/content-lint-seo
Add CI gate enforcing per-page description: front matter
2 parents ce630ff + 345a728 commit 2889bed

4 files changed

Lines changed: 191 additions & 0 deletions

File tree

.github/workflows/content-lint.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Content lint: gate pull requests on textbook authoring conventions.
2+
#
3+
# Runs lightweight checks on the Markdown content. Separate from the deploy
4+
# workflow (jekyll.yml) on purpose: a content-convention miss should block a
5+
# MERGE, but never take down the live site. This is also the home for future
6+
# content gates (e.g. the #99 code-block standardization lint).
7+
name: Content lint
8+
9+
on:
10+
pull_request:
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
seo-frontmatter:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
23+
- name: Setup Python
24+
uses: actions/setup-python@v5
25+
with:
26+
python-version: "3.x"
27+
28+
- name: Check per-page SEO front matter (description:)
29+
run: python scripts/check_seo_frontmatter.py

scripts/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,27 @@ Python utilities that bulk-edit or generate content across the textbook. They ar
2222

2323
| Script | Purpose | Apply flag |
2424
|---|---|---|
25+
| [`check_seo_frontmatter.py`](check_seo_frontmatter.py) | **CI gate** — fail if any published page is missing `description:` front matter (reminds about MP4-hero pages lacking a poster). Read-only. | _(none)_ |
2526
| [`generate_og_posters.py`](generate_og_posters.py) | Generate static OG/social-card poster images from a page's hero `<video>` (MP4) via ffmpeg, and set `image:` front matter. | `--run` |
2627
| [`fix_embedded_media.py`](fix_embedded_media.py) | Normalize `<video>` inline styles and wrap bare YouTube iframes responsively. | `--run` |
2728
| [`update_lesson_nav.py`](update_lesson_nav.py) | Migrate old `.btn` lesson nav to card-style `<nav class="lesson-nav">` (rewrites `.md``.html`). | `--run` |
2829
| [`fix_arduino_urls.py`](fix_arduino_urls.py) | Migrate old `arduino.cc` URLs to `docs.arduino.cc`. **Untested/brittle — use with care.** | `--apply` |
2930

3031
## Details
3132

33+
### `check_seo_frontmatter.py`
34+
35+
Enforces the per-page SEO convention: every published page must set `description:`.
36+
Run by the **Content lint** GitHub Actions workflow (`.github/workflows/content-lint.yml`)
37+
on every pull request — it exits non-zero (failing the PR check, but never the deploy) if
38+
any page is missing it. Pages marked `nav_exclude: true`/`search_exclude: true`, plus the
39+
contributor docs and deprecated pages, are exempt. `image:` is advisory: the script only
40+
prints a reminder when an MP4-hero page has no poster yet. Read-only; takes no flags.
41+
42+
```bash
43+
python scripts/check_seo_frontmatter.py # exit 0 = all good, 1 = a page is missing description:
44+
```
45+
3246
### `generate_og_posters.py`
3347

3448
For pages whose **first/hero media is an MP4 `<video>`**, extracts a representative

scripts/check_seo_frontmatter.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
check_seo_frontmatter.py — CI gate: every published page must set `description:`.
3+
4+
Per-page `description:` (and, where possible, `image:`) drives search snippets and
5+
social link-preview cards via jekyll-seo-tag. This script enforces the convention so
6+
new content (and in-flight branches) can't quietly regress to the generic site card.
7+
See the "SEO and social cards" section of website-dev.md.
8+
9+
Rules:
10+
- A "page" = a .md file with YAML front matter containing `layout:`.
11+
- Every page MUST have a non-empty `description:` — EXCEPT:
12+
* pages marked draft via `nav_exclude: true` or `search_exclude: true`, and
13+
* paths in IGNORE (contributor docs, deprecated pages).
14+
A draft becomes subject to the rule as soon as it's published (nav_exclude removed).
15+
- `image:` is ADVISORY: a page whose hero is an MP4 <video> but has no `image:`
16+
yet gets a non-fatal reminder to run scripts/generate_og_posters.py. Pages may
17+
legitimately have no image (they fall back to the site card).
18+
19+
Exit code: 1 if any required page is missing `description:` (fails the CI check);
20+
0 otherwise. Advisory image reminders never affect the exit code.
21+
22+
Usage:
23+
python scripts/check_seo_frontmatter.py
24+
"""
25+
26+
import re
27+
import sys
28+
from pathlib import Path
29+
30+
DOCS_DIR = "."
31+
SKIP_DIRS = {"_site", ".git", "node_modules", "vendor", ".jekyll-cache",
32+
"scripts", "_includes", "_layouts", "_data", "_sass", "assets"}
33+
34+
# Pages exempt from the description: requirement (contributor docs, deprecated).
35+
IGNORE = {
36+
"website-dev.md", "website-install.md", "teaching-notes.md",
37+
"website-content-ideas.md", "README.md", "LICENSE.md", "CLAUDE.md",
38+
"404.md", "arduino/potentiometers-old.md",
39+
}
40+
41+
FRONT_MATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
42+
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
43+
MEDIA_TOKEN_RE = re.compile(r"<video\b|!\[|<img\b|<iframe\b", re.IGNORECASE)
44+
MP4_SOURCE_RE = re.compile(r'<source\b[^>]*\.mp4"', re.IGNORECASE)
45+
46+
47+
def front_matter(content):
48+
m = FRONT_MATTER_RE.match(content)
49+
return (m.group(1), content[m.end():]) if m else (None, content)
50+
51+
52+
def fm_has(fm, key):
53+
"""True if front matter has a non-empty value for `key`."""
54+
m = re.search(rf"^{key}:\s*(.+?)\s*$", fm, re.MULTILINE)
55+
return bool(m and m.group(1).strip() not in ("", '""', "''"))
56+
57+
58+
def fm_true(fm, key):
59+
return bool(re.search(rf"^{key}:\s*true\s*$", fm, re.MULTILINE | re.IGNORECASE))
60+
61+
62+
def hero_is_mp4(body):
63+
visible = HTML_COMMENT_RE.sub("", body)
64+
first = MEDIA_TOKEN_RE.search(visible)
65+
if not first or not visible[first.start():first.end()].lower().startswith("<video"):
66+
return False
67+
return bool(MP4_SOURCE_RE.search(visible, first.start()))
68+
69+
70+
def rel(p):
71+
return str(p).replace("\\", "/").lstrip("./")
72+
73+
74+
def main():
75+
missing_desc = []
76+
image_reminders = []
77+
checked = 0
78+
79+
for path in sorted(Path(DOCS_DIR).rglob("*.md")):
80+
if any(part in SKIP_DIRS for part in path.parts):
81+
continue
82+
relpath = rel(path)
83+
if relpath in IGNORE:
84+
continue
85+
86+
fm, body = front_matter(path.read_text(encoding="utf-8"))
87+
if fm is None or not re.search(r"^layout:", fm, re.MULTILINE):
88+
continue # not a page
89+
if fm_true(fm, "nav_exclude") or fm_true(fm, "search_exclude"):
90+
continue # draft / hidden
91+
92+
checked += 1
93+
if not fm_has(fm, "description"):
94+
missing_desc.append(relpath)
95+
if not fm_has(fm, "image") and hero_is_mp4(body):
96+
image_reminders.append(relpath)
97+
98+
print(f"Checked {checked} published page(s).")
99+
100+
if image_reminders:
101+
print(f"\nReminder ({len(image_reminders)}): MP4-hero page(s) with no `image:` "
102+
f"— run `python scripts/generate_og_posters.py --run`:")
103+
for p in image_reminders:
104+
print(f" - {p}")
105+
106+
if missing_desc:
107+
print(f"\nERROR: {len(missing_desc)} published page(s) missing `description:` "
108+
f"front matter:")
109+
for p in missing_desc:
110+
print(f" - {p}")
111+
print("\nAdd a `description:` (see website-dev.md -> 'SEO and social cards'). "
112+
"Drafts can set `nav_exclude: true` to defer.")
113+
return 1
114+
115+
print("\nAll published pages have `description:`. OK")
116+
return 0
117+
118+
119+
if __name__ == "__main__":
120+
sys.exit(main())

website-dev.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,34 @@ To verify after a build, grep the output, e.g.:
111111
grep -oiE '<meta (name|property)="(og:image|og:description|description)" content="[^"]*"' _site/arduino/led-fade.html
112112
```
113113

114+
### New pages and enforcement
115+
116+
This is **required**, not optional. A CI check (`scripts/check_seo_frontmatter.py`, run by
117+
the **Content lint** workflow on every pull request) fails the PR if any published page is
118+
missing `description:`. So when you author a new lesson, start from this minimal front matter:
119+
120+
```yaml
121+
---
122+
layout: default
123+
title: "Your Lesson Title"
124+
description: "One or two sentences (≤160 chars) on what the reader learns or builds."
125+
# image: ← add per the rules above; for an MP4 hero, run the poster script (below) instead
126+
parent: Your Section
127+
nav_order: 1
128+
---
129+
```
130+
131+
If a page isn't ready to publish, mark it `nav_exclude: true` (or `search_exclude: true`) and
132+
the check skips it until you publish it. The `image:` key is advisory — the check only *reminds*
133+
you when an MP4-hero page has no poster yet.
134+
135+
For a new page whose hero is an **MP4 `<video>`**, generate its social poster (and have `image:`
136+
set for you) with:
137+
138+
```bash
139+
python scripts/generate_og_posters.py --run <module>/<your-page>.md
140+
```
141+
114142
## Code highlighting
115143
<!-- Code snippet highlighting: https://jekyllrb.com/docs/liquid/tags/#code-snippet-highlighting -->
116144

0 commit comments

Comments
 (0)