|
22 | 22 | import re |
23 | 23 | import sys |
24 | 24 | from pathlib import Path |
25 | | -from urllib.parse import urljoin |
| 25 | +from urllib.parse import unquote, urljoin, urlparse |
26 | 26 |
|
27 | 27 | import click |
28 | 28 | import yaml |
|
59 | 59 | ) |
60 | 60 | # <link rel="canonical"> and friends are metadata, not asset loads. |
61 | 61 | LINK_META_RE = re.compile(r'rel="(canonical|alternate|manifest)"', re.IGNORECASE) |
| 62 | +OG_IMAGE_RE = re.compile(r'<meta property="og:image" content="([^"]+)"') |
62 | 63 | TAGS_LISTING_RE = re.compile(r'<h2 id="tag:([^"]+)"') |
63 | 64 | # Zensical (0.0.58+) renders tag chips as links to their /tags/ listing anchor |
64 | 65 | # natively; this just asserts every chip's target actually exists there. |
@@ -244,11 +245,49 @@ def check_external_assets(pages: list[Path]) -> None: |
244 | 245 | report("external-assets", True, "no unexpected third-party hosts", required=True) |
245 | 246 |
|
246 | 247 |
|
| 248 | +def check_social_cards(site: Path, pages: list[Path]) -> None: |
| 249 | + """Every content page carries an og:image whose card was written to site/.""" |
| 250 | + with_card = 0 |
| 251 | + missing: list[str] = [] |
| 252 | + dangling: dict[str, str] = {} |
| 253 | + for page in pages: |
| 254 | + body = page.read_text(encoding="utf-8", errors="replace") |
| 255 | + # Redirect stubs and the 404 page get no card. |
| 256 | + if REFRESH_RE.search(body) or page == site / "404.html": |
| 257 | + continue |
| 258 | + found = OG_IMAGE_RE.search(body) |
| 259 | + if not found: |
| 260 | + missing.append(page.relative_to(site).as_posix()) |
| 261 | + continue |
| 262 | + with_card += 1 |
| 263 | + path = unquote(urlparse(html.unescape(found.group(1))).path) |
| 264 | + # site_url's path and, in MIKE_DOCS_VERSION builds, the version lead the |
| 265 | + # URL path but are not directories in site/ - drop segments until it lands. |
| 266 | + segments = path.strip("/").split("/") |
| 267 | + if not any((site / "/".join(segments[i:])).is_file() for i in range(len(segments))): |
| 268 | + dangling.setdefault(path, page.relative_to(site).as_posix()) |
| 269 | + |
| 270 | + report( |
| 271 | + "social-cards", |
| 272 | + with_card > 0 and not missing, |
| 273 | + f"og:image on {with_card} page(s)" |
| 274 | + if not missing |
| 275 | + else f"{len(missing)} page(s) without og:image, e.g. " + ", ".join(missing[:3]), |
| 276 | + required=True, |
| 277 | + ) |
| 278 | + report( |
| 279 | + "social-cards-resolve", |
| 280 | + not dangling, |
| 281 | + "every og:image card exists in site/" |
| 282 | + if not dangling |
| 283 | + else f"{len(dangling)} card(s) missing from site/, e.g. " |
| 284 | + + ", ".join(f"{u} (on {p})" for u, p in sorted(dangling.items())[:3]), |
| 285 | + required=True, |
| 286 | + ) |
| 287 | + |
| 288 | + |
247 | 289 | def check_pending(site: Path, pages: list[Path]) -> None: |
248 | 290 | """Features still missing from Zensical - warn only, never fail.""" |
249 | | - social = sum(1 for p in pages if 'property="og:image"' in p.read_text(encoding="utf-8", errors="replace")) |
250 | | - report("social-cards", social > 0, f"og:image on {social} pages (backlog #37)", required=False) |
251 | | - |
252 | 291 |
|
253 | 292 | revision = sum(1 for p in pages if "Last update" in p.read_text(encoding="utf-8", errors="replace")) |
254 | 293 | report("revision-dates", revision > 0, f"last-update on {revision} pages (backlog #18)", required=False) |
@@ -283,6 +322,7 @@ def check_zensical_output(site_dir: str, config_file: str) -> None: |
283 | 322 | check_comments(site, pages) |
284 | 323 | check_external_assets(pages) |
285 | 324 | check_tag_chip_links(site, pages) |
| 325 | + check_social_cards(site, pages) |
286 | 326 | print() |
287 | 327 | check_pending(site, pages) |
288 | 328 | print() |
|
0 commit comments