mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 04:36:00 -10:00
* docs(cheats): community cheat store design and plan (#1071) Refs #1071 Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs(cheats): map printf_notify to app toasts Refs #1071 Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(cheat-import): BigPEmu API map; int_full settings omitted; printf_notify ignored Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cheat-import): refuse side effects in message args; scope Unsupported.call to API gaps Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(cheat-import): committed cartridge identity table (hashes only) scan_cartridges.py walks a ROM directory (read-only) and writes a sorted FNV1a64 -> CRC32 table with size, title and name; no ROM bytes or paths. load_cartridges rejects duplicate fnv/crc, bad hex and format != 1. The generated cartridges.json (152 rows) lets CI map community script hashes to the CRC32 the app binds packs to without any ROMs. Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(cheat-import): community cheat pack generator (#1071) build_packs.py converts every pinned community C cheat source with convert.py and writes the site/cheats tree: per-cartridge .cht packs (one per CRC32, sources merged, identical cheats deduped, same-name cheats suffixed), manifest.json, the unsupported.json worklist with per-call API usage, index.html and README. Deterministic, bounded fetch of one codeload tarball per source, --source-dir for local checkouts, --summary and --bump for the bump workflow. sources.json pins jguffer/BigPEmu_Scripts at 2421150. Co-Authored-By: Claude Opus 5.5 <[email protected]> * data(cheats): generate community packs from jguffer/BigPEmu_Scripts@2421150 23 cartridge packs (72 cheats, 9 partial) plus the unsupported.json worklist (55 entries), manifest and index, generated with build_packs.py from the pinned codeload tarball; byte-identical to a local checkout run. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cheat-import): worklist unmatched literals beside a hit; bound fetch errors and member count - identity row for table-missing literals in a file that also has a hit - http.client.HTTPException during fetch is a FetchError (exit 2) - tar expansion cap counts a header block per member and caps member count - Collector uses the api_map given to build() Co-Authored-By: Claude Opus 5.5 <[email protected]> * data(cheats): regenerate for generator fix (unmatched-literal identity rows) Co-Authored-By: Claude Opus 5.5 <[email protected]> * feat(site): publish community cheat packs under /cheats/ build_site.py copies the committed site/cheats tree verbatim; check_site.py gains check_cheats (manifest format/sha256/bytes/CRC guard, unlisted .cht, unsupported.json, index.html links, 256 KiB manifest and 1 MiB tree limits) with unit tests. pages.yml watches site/cheats and tools/cheat-import. The converter README now documents the author's permission, the credit, the sources -> cartridges -> packs workflow and how to add a source; the community_test harness and its fixtures are removed (the committed packs and the regeneration diff are the conversion contract). Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(cheats): regen-diff job, concurrency, weekly pin-bump PR cheat-import.yml: add site/cheats/** to the path filters, a per-ref concurrency group, drop the community_test.py fetch step (the files are gone), and add a packs job that regenerates twice, diffs the runs and the committed tree, flags unmapped BigPEmu calls, then runs the site-check tests and build_site + check_site. cheat-packs-bump.yml: weekly (Mon 06:23 UTC) poll of each pinned github source; on movement, bump the pin, regenerate, validate, force-push the fixed branch cheats/pin-bump and open or refresh a develop PR (validation failures head the body instead of being dropped), then dispatch cheat-import.yml on the branch because GITHUB_TOKEN PRs do not fire pull_request workflows. test_committed_sources no longer pins the commit/date value: every bump PR would otherwise fail the unit tests; only the shape is asserted. Refs #1071 Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(cheats): gate bump workflow to the upstream repo, drop bot trailer Only libretro/virtualjaguar-libretro opens bump PRs (the Provenance-Emu fork runs crons too), with a 30 minute job timeout. The bot-authored bump commit no longer carries a Claude co-author trailer. Refs #1071 Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(cheats): pin pycparser, force LF, add job timeouts Pin pycparser==3.11 (newest release that installs on the Python 3.10 CI leg; 2.22, 2.23, 3.0, 3.11 and 3.12 all regenerate byte-identical packs). Add a root .gitattributes forcing LF for the generator and generated tree so pack bytes cannot drift by checkout. Give both jobs a 20 minute timeout. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(cheats): dedupe pack header title, refuse control-character paths The pack's first line printed the title twice when the cartridge name began with the title. Print the name alone in that case. Fetch and local-dir reads now fail closed on any path containing a control character, since names can reach ::warning:: annotations and PR bodies. Co-Authored-By: Claude Opus 5.5 <[email protected]> * data(cheats): regenerate packs with deduplicated header title Line 1 of each pack no longer repeats the title when the cartridge name already starts with it. Regenerated from the pinned source commit; two runs and a --source-dir run are byte-identical. Co-Authored-By: Claude Opus 5.5 <[email protected]> * ci(cheats): even indentation on a bump-workflow continuation line editorconfig-checker wants multiples of 2 in workflow files. Refs #1071 Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
442 lines
18 KiB
Python
442 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Post-build checks for the generated site. Stdlib only.
|
|
|
|
`build_site.py` fails loudly on bad *input*; this checks the *output*, so the
|
|
SEO and accessibility guarantees documented in docs/site-maintenance.md stay
|
|
true as pages are edited. Run in CI right after the build:
|
|
|
|
python3 scripts/build_site.py && python3 scripts/check_site.py _site
|
|
|
|
Checks, per page: exactly one <h1>, lang="en", non-empty descriptive alt on
|
|
every image, every local href/src resolves inside the output, no leftover
|
|
{{PLACEHOLDER}}, canonical == og:url == the sitemap <loc>, the full Open
|
|
Graph / Twitter card set present with an absolute image that exists
|
|
(twitter:image == og:image, width/height tags matching the PNG, under 1 MB),
|
|
rel=icon and rel=apple-touch-icon links present, JSON-LD
|
|
parses and is the expected @type. Site-wide: titles and descriptions are
|
|
distinct, sitemap.xml is well-formed and lists exactly the generated pages,
|
|
robots.txt carries an absolute Sitemap: line, no page contains an em or en
|
|
dash (house copy standard; use a hyphen), and no page references a
|
|
tracker, an external asset, or any <script> other than JSON-LD blocks and
|
|
deferred <script src> tags pointing at local files under assets/js/ that
|
|
exist in the output (inline and external scripts stay hard failures). The
|
|
site currently ships no script beyond JSON-LD; that allowance is the
|
|
contract for future pages, not something in use.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from urllib.parse import urldefrag
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from build_site import ALL_PAGES, SITE_BASE, page_url, png_size # noqa: E402
|
|
|
|
# Substrings that must never appear: analytics, tag managers, CDNs, web fonts.
|
|
FORBIDDEN = ("googletagmanager", "google-analytics", "gtag(", "plausible.io",
|
|
"matomo", "cdn.jsdelivr", "unpkg.com", "cdnjs.cloudflare",
|
|
"fonts.googleapis", "fonts.gstatic")
|
|
|
|
# Em dash and en dash. See the copy-standard check in check_page().
|
|
DASH_RE = re.compile("[\u2014\u2013]")
|
|
|
|
REQUIRED_META = ("og:type", "og:title", "og:description", "og:image",
|
|
"og:image:width", "og:image:height", "og:image:alt",
|
|
"twitter:card", "twitter:title", "twitter:description",
|
|
"twitter:image")
|
|
|
|
# Share-card budget: what the networks document for summary_large_image.
|
|
SHARE_MAX_BYTES = 1000 * 1000
|
|
# Head <link>s every page must carry, by rel; each href is also covered by
|
|
# the local-link check, so a missing file fails there.
|
|
REQUIRED_ICON_RELS = ("icon", "apple-touch-icon")
|
|
|
|
FAILS = []
|
|
|
|
|
|
def check(cond, msg):
|
|
if not cond:
|
|
FAILS.append(msg)
|
|
return cond
|
|
|
|
|
|
class PageParser(HTMLParser):
|
|
def __init__(self):
|
|
HTMLParser.__init__(self, convert_charrefs=True)
|
|
self.h1 = 0
|
|
self.imgs = []
|
|
self.links = []
|
|
self.link_rels = set()
|
|
self.meta = {}
|
|
self.canonical = None
|
|
self.lang = None
|
|
self.title = ""
|
|
self.ld = []
|
|
self._in_title = False
|
|
self._in_ld = False
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
a = dict(attrs)
|
|
if tag == "html":
|
|
self.lang = a.get("lang")
|
|
elif tag == "h1":
|
|
self.h1 += 1
|
|
elif tag == "title":
|
|
self._in_title = True
|
|
elif tag == "img":
|
|
self.imgs.append((a.get("src"), a.get("alt")))
|
|
elif tag == "a" and a.get("href"):
|
|
self.links.append(a["href"])
|
|
elif tag == "link":
|
|
if a.get("rel") == "canonical":
|
|
self.canonical = a.get("href")
|
|
elif a.get("href"):
|
|
self.links.append(a["href"])
|
|
self.link_rels.add(a.get("rel"))
|
|
elif tag == "meta":
|
|
key = a.get("name") or a.get("property")
|
|
if key:
|
|
self.meta[key] = a.get("content", "")
|
|
elif tag == "script" and a.get("type") == "application/ld+json":
|
|
self._in_ld = True
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == "title":
|
|
self._in_title = False
|
|
elif tag == "script":
|
|
self._in_ld = False
|
|
|
|
def handle_data(self, data):
|
|
if self._in_title:
|
|
self.title += data
|
|
if self._in_ld:
|
|
self.ld.append(data)
|
|
|
|
|
|
def check_page(out, name):
|
|
path = out / name
|
|
if not check(path.is_file(), "missing output page %s" % name):
|
|
return None
|
|
text = path.read_text(encoding="utf-8")
|
|
left = re.search(r"\{\{[A-Z_]+\}\}", text)
|
|
check(not left, "%s: unresolved placeholder %s"
|
|
% (name, left.group(0) if left else ""))
|
|
|
|
pp = PageParser()
|
|
pp.feed(text)
|
|
url = page_url(name)
|
|
|
|
check(pp.lang == "en", "%s: <html lang> is %r, expected 'en'"
|
|
% (name, pp.lang))
|
|
check(pp.h1 == 1, "%s: %d <h1> elements, expected exactly 1"
|
|
% (name, pp.h1))
|
|
check(pp.title.strip(), "%s: empty <title>" % name)
|
|
check(pp.meta.get("description"), "%s: no meta description" % name)
|
|
|
|
for src, alt in pp.imgs:
|
|
check(alt and alt.strip(),
|
|
"%s: <img src=%r> has no alt text" % (name, src))
|
|
check(alt is None or len(alt) > 15,
|
|
"%s: <img src=%r> alt is not descriptive: %r" % (name, src, alt))
|
|
|
|
for href in pp.links + [s for s, _ in pp.imgs]:
|
|
if not href or re.match(r"^(https?:|mailto:|#|data:)", href):
|
|
continue
|
|
# The home page is canonical as the directory form, so nothing may
|
|
# link it as index.html -- that would point crawlers at the duplicate
|
|
# URL rel=canonical explicitly rejects.
|
|
check(urldefrag(href)[0] != "index.html",
|
|
"%s: links the home page as %r; use './' to match its canonical "
|
|
"URL" % (name, href))
|
|
target = urldefrag(href)[0]
|
|
if target:
|
|
check((out / target).exists(),
|
|
"%s: local link %r resolves to nothing" % (name, href))
|
|
|
|
check(pp.canonical == url, "%s: canonical %r != expected %r"
|
|
% (name, pp.canonical, url))
|
|
check(pp.meta.get("og:url") == url, "%s: og:url %r != canonical %r"
|
|
% (name, pp.meta.get("og:url"), url))
|
|
for key in REQUIRED_META:
|
|
check(pp.meta.get(key), "%s: missing %s" % (name, key))
|
|
check(pp.meta.get("twitter:card") == "summary_large_image",
|
|
"%s: twitter:card is %r" % (name, pp.meta.get("twitter:card")))
|
|
for key in ("og:image", "twitter:image"):
|
|
val = pp.meta.get(key, "")
|
|
check(val.startswith(SITE_BASE),
|
|
"%s: %s is not an absolute site URL: %r" % (name, key, val))
|
|
rel = val[len(SITE_BASE):] if val.startswith(SITE_BASE) else ""
|
|
check(rel and (out / rel).is_file(),
|
|
"%s: %s does not point at a published file: %r"
|
|
% (name, key, val))
|
|
check(pp.meta.get("twitter:image") == pp.meta.get("og:image"),
|
|
"%s: twitter:image %r != og:image %r" % (
|
|
name, pp.meta.get("twitter:image"), pp.meta.get("og:image")))
|
|
img = pp.meta.get("og:image", "")
|
|
img_path = out / img[len(SITE_BASE):] if img.startswith(SITE_BASE) else None
|
|
if img_path is not None and img_path.is_file():
|
|
w, h = png_size(img_path)
|
|
check((str(w), str(h)) == (pp.meta.get("og:image:width"),
|
|
pp.meta.get("og:image:height")),
|
|
"%s: og:image is %dx%d but the tags say %sx%s" % (
|
|
name, w, h, pp.meta.get("og:image:width"),
|
|
pp.meta.get("og:image:height")))
|
|
check(img_path.stat().st_size < SHARE_MAX_BYTES,
|
|
"%s: og:image %s is %d bytes, over the 1 MB card budget"
|
|
% (name, img, img_path.stat().st_size))
|
|
for rel in REQUIRED_ICON_RELS:
|
|
check(rel in pp.link_rels, "%s: no <link rel=%r>" % (name, rel))
|
|
|
|
check(pp.ld, "%s: no JSON-LD block" % name)
|
|
want = "SoftwareApplication" if name == "index.html" else "BreadcrumbList"
|
|
for blob in pp.ld:
|
|
try:
|
|
data = json.loads(blob)
|
|
except ValueError as exc:
|
|
FAILS.append("%s: JSON-LD does not parse: %s" % (name, exc))
|
|
continue
|
|
check(data.get("@context") == "https://schema.org",
|
|
"%s: JSON-LD @context %r" % (name, data.get("@context")))
|
|
check(data.get("@type") == want, "%s: JSON-LD @type %r, expected %r"
|
|
% (name, data.get("@type"), want))
|
|
if want == "SoftwareApplication":
|
|
check(re.match(r"^\d+\.\d+\.\d+", data.get("softwareVersion", "")),
|
|
"%s: softwareVersion %r" % (name, data.get("softwareVersion")))
|
|
check(data.get("isAccessibleForFree") is True,
|
|
"%s: isAccessibleForFree is not true" % name)
|
|
check("gpl-3.0" in data.get("license", ""),
|
|
"%s: license %r" % (name, data.get("license")))
|
|
# We have no ratings or reviews; publishing any would be a lie.
|
|
check("aggregateRating" not in data and "review" not in data,
|
|
"%s: JSON-LD carries rating/review data we do not have"
|
|
% name)
|
|
else:
|
|
items = data.get("itemListElement", [])
|
|
check(len(items) == 2,
|
|
"%s: breadcrumb has %d items, expected 2" % (name, len(items)))
|
|
check(items and items[-1].get("item") == url,
|
|
"%s: breadcrumb tail %r != %r"
|
|
% (name, items[-1].get("item") if items else None, url))
|
|
|
|
for bad in FORBIDDEN:
|
|
check(bad not in text, "%s: references %r -- no trackers or external "
|
|
"assets are allowed" % (name, bad))
|
|
|
|
# Copy standard: zero em/en dashes anywhere the reader can see, title
|
|
# attributes and <title> included. It is a house style (a hyphen is what
|
|
# the 1994 readme voice this site is written in would have used) and it
|
|
# is fragile, because dashes arrive from three directions: prose someone
|
|
# types into a fragment, the layout template, and the generated boot
|
|
# matrices -- build_site.normalize_dashes covers the third. Without a
|
|
# check the rule survives exactly until the next edit.
|
|
for m in DASH_RE.finditer(text):
|
|
s = max(0, m.start() - 45)
|
|
check(False, "%s: em/en dash in output, use a hyphen: ...%s..."
|
|
% (name, text[s:m.end() + 25].replace("\n", " ")))
|
|
break # one report per page is enough to send someone looking
|
|
# Scripts: JSON-LD blocks, or <script src> pointing at a LOCAL file
|
|
# under assets/js/ that exists in the output. The site currently ships
|
|
# NO script at all -- the period chrome is pure CSS -- so this loop
|
|
# normally has nothing to inspect; the rule stays as the contract for
|
|
# any future page that needs behaviour. External src and inline
|
|
# non-JSON-LD scripts remain hard failures: no CDNs, no trackers, no
|
|
# surprises (see docs/site-maintenance.md).
|
|
for m in re.finditer(r"<script([^>]*)>", text):
|
|
attrs = m.group(1)
|
|
if 'type="application/ld+json"' in attrs:
|
|
continue
|
|
srcm = re.search(r'src="([^"]*)"', attrs)
|
|
if not check(srcm is not None,
|
|
"%s: inline non-JSON-LD <script%s>" % (name, attrs)):
|
|
continue
|
|
src = srcm.group(1)
|
|
check(not re.match(r"^(https?:)?//", src),
|
|
"%s: external script src %r -- vendor it locally" % (name, src))
|
|
check(re.match(r"^assets/js/[\w.-]+\.js$", src),
|
|
"%s: script src %r is outside assets/js/" % (name, src))
|
|
check((out / src).is_file(),
|
|
"%s: script src %r does not exist in the output" % (name, src))
|
|
check(re.search(r"\bdefer\b", attrs) is not None,
|
|
"%s: script src %r must be deferred -- effects are progressive "
|
|
"enhancement, not render-blocking" % (name, src))
|
|
return pp
|
|
|
|
|
|
def check_sitemap(out):
|
|
path = out / "sitemap.xml"
|
|
if not check(path.is_file(), "missing sitemap.xml"):
|
|
return
|
|
try:
|
|
root = ET.fromstring(path.read_text(encoding="utf-8"))
|
|
except ET.ParseError as exc:
|
|
FAILS.append("sitemap.xml is not well-formed XML: %s" % exc)
|
|
return
|
|
ns = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
|
|
check(root.tag == ns + "urlset", "sitemap root tag is %r" % root.tag)
|
|
locs = []
|
|
for u in root.findall(ns + "url"):
|
|
locs.append(u.findtext(ns + "loc"))
|
|
lastmod = u.findtext(ns + "lastmod")
|
|
check(lastmod and re.match(r"^\d{4}-\d{2}-\d{2}$", lastmod),
|
|
"sitemap lastmod %r is not YYYY-MM-DD" % lastmod)
|
|
check(set(locs) == set(page_url(p) for p in ALL_PAGES),
|
|
"sitemap URLs %r do not match the generated pages" % sorted(locs))
|
|
|
|
|
|
def check_robots(out):
|
|
path = out / "robots.txt"
|
|
if not check(path.is_file(), "missing robots.txt"):
|
|
return
|
|
body = path.read_text(encoding="utf-8")
|
|
check("User-agent: *" in body, "robots.txt has no 'User-agent: *' line")
|
|
check("Sitemap: " + SITE_BASE + "sitemap.xml" in body,
|
|
"robots.txt has no absolute 'Sitemap:' line")
|
|
|
|
|
|
CHEATS_MANIFEST_MAX = 256 * 1024
|
|
CHEATS_TREE_MAX = 1024 * 1024
|
|
CRC_RE = re.compile(r'^\s*vj_rom_crc32\s*=\s*"([0-9A-Fa-f]{8})"', re.M)
|
|
|
|
|
|
def check_cheats(out):
|
|
"""Validate the committed community cheat packs copied to <out>/cheats.
|
|
|
|
Returns the list of problems found; an absent cheats/ directory is not
|
|
an error.
|
|
"""
|
|
root = out / "cheats"
|
|
if not root.is_dir():
|
|
return []
|
|
problems = []
|
|
|
|
def bad(msg):
|
|
problems.append("cheats: " + msg)
|
|
|
|
manifest = None
|
|
mpath = root / "manifest.json"
|
|
if not mpath.is_file():
|
|
bad("manifest.json is missing")
|
|
else:
|
|
if mpath.stat().st_size > CHEATS_MANIFEST_MAX:
|
|
bad("manifest.json is %d bytes, over the %d byte limit"
|
|
% (mpath.stat().st_size, CHEATS_MANIFEST_MAX))
|
|
try:
|
|
manifest = json.loads(mpath.read_text(encoding="utf-8"))
|
|
except (ValueError, OSError) as e:
|
|
bad("manifest.json does not parse: %s" % e)
|
|
total = sum(f.stat().st_size for f in root.rglob("*") if f.is_file())
|
|
if total > CHEATS_TREE_MAX:
|
|
bad("cheats tree is %d bytes, over the %d byte limit"
|
|
% (total, CHEATS_TREE_MAX))
|
|
|
|
listed = set()
|
|
if isinstance(manifest, dict):
|
|
if manifest.get("format") != 1:
|
|
bad("manifest format is %r, expected 1" % (manifest.get("format"),))
|
|
packs = manifest.get("packs")
|
|
if not isinstance(packs, list):
|
|
bad("manifest has no packs list")
|
|
packs = []
|
|
for pk in packs:
|
|
rel = pk.get("file") if isinstance(pk, dict) else None
|
|
if not isinstance(rel, str) or not rel.startswith("packs/") \
|
|
or ".." in rel.split("/"):
|
|
bad("pack entry has a bad file path: %r" % (rel,))
|
|
continue
|
|
listed.add(rel)
|
|
f = root / rel
|
|
if not f.is_file():
|
|
bad("%s is listed but missing" % rel)
|
|
continue
|
|
data = f.read_bytes()
|
|
if hashlib.sha256(data).hexdigest() != pk.get("sha256"):
|
|
bad("%s sha256 does not match the manifest" % rel)
|
|
if len(data) != pk.get("bytes"):
|
|
bad("%s is %d bytes, manifest says %r"
|
|
% (rel, len(data), pk.get("bytes")))
|
|
m = CRC_RE.search(data.decode("utf-8", "replace"))
|
|
crc = m.group(1).upper() if m else None
|
|
if crc is None:
|
|
bad("%s has no vj_rom_crc32 guard" % rel)
|
|
elif crc != str(pk.get("crc32", "")).upper():
|
|
bad("%s vj_rom_crc32 %s != manifest crc32 %r"
|
|
% (rel, crc, pk.get("crc32")))
|
|
msgs = pk.get("messages", [])
|
|
if not (isinstance(msgs, list)
|
|
and all(isinstance(x, str) for x in msgs)):
|
|
bad("%s messages must be a list of strings" % rel)
|
|
pdir = root / "packs"
|
|
for f in sorted(pdir.glob("*.cht")) if pdir.is_dir() else []:
|
|
if "packs/" + f.name not in listed:
|
|
bad("packs/%s is not listed in the manifest" % f.name)
|
|
|
|
upath = root / "unsupported.json"
|
|
if not upath.is_file():
|
|
bad("unsupported.json is missing")
|
|
else:
|
|
try:
|
|
json.loads(upath.read_text(encoding="utf-8"))
|
|
except (ValueError, OSError) as e:
|
|
bad("unsupported.json does not parse: %s" % e)
|
|
|
|
ipath = root / "index.html"
|
|
if not ipath.is_file():
|
|
bad("index.html is missing")
|
|
else:
|
|
pp = PageParser()
|
|
pp.feed(ipath.read_text(encoding="utf-8"))
|
|
for href in pp.links:
|
|
if not href or re.match(r"^(https?:|mailto:|#|data:)", href):
|
|
continue
|
|
target = urldefrag(href)[0]
|
|
if target and not (root / target).resolve().exists():
|
|
bad("index.html: local link %r resolves to nothing" % href)
|
|
return problems
|
|
|
|
|
|
def main():
|
|
out = Path(sys.argv[1] if len(sys.argv) > 1 else "_site").resolve()
|
|
if not out.is_dir():
|
|
sys.stderr.write("no such build output directory: %s\n" % out)
|
|
return 2
|
|
|
|
parsed = {}
|
|
for name in ALL_PAGES:
|
|
pp = check_page(out, name)
|
|
if pp is not None:
|
|
parsed[name] = pp
|
|
|
|
titles = dict((n, p.title.strip()) for n, p in parsed.items())
|
|
descs = dict((n, p.meta.get("description")) for n, p in parsed.items())
|
|
check(len(set(titles.values())) == len(titles),
|
|
"duplicate <title> across pages: %r" % titles)
|
|
check(len(set(descs.values())) == len(descs),
|
|
"duplicate meta descriptions across pages")
|
|
for name, title in titles.items():
|
|
if name != "index.html":
|
|
check(title.endswith("Virtual Jaguar libretro"),
|
|
"%s: title %r should end with the site name" % (name, title))
|
|
|
|
check_sitemap(out)
|
|
check_robots(out)
|
|
for problem in check_cheats(out):
|
|
check(False, problem)
|
|
|
|
if FAILS:
|
|
sys.stderr.write("\nFAIL: check_site.py found %d problem(s):\n"
|
|
% len(FAILS))
|
|
for f in FAILS:
|
|
sys.stderr.write(" - %s\n" % f)
|
|
return 1
|
|
print("check_site.py: OK -- %d pages, sitemap and robots.txt verified"
|
|
% len(parsed))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|