diff --git a/scripts/check_links.py b/scripts/check_links.py index ba2907ee..d65a91b5 100644 --- a/scripts/check_links.py +++ b/scripts/check_links.py @@ -8,8 +8,13 @@ the browser verbatim and 404s. This checker works on the built output instead, so it sees exactly what a -reader's browser would request: every ```` must resolve to a real file -in the build, and any fragment must match an ``id`` on the target page. +reader's browser would request: every ```` and ```` must +resolve to a real file in the build, and any fragment on an HTML target must +match an ``id`` on that page. + +Checking ```` also keeps ``svg_theme.py`` honest: that hook rewrites +figure tags to point at variants it generates separately, and nothing else +notices when the two halves disagree. Usage: python scripts/check_links.py [site_dir] @@ -31,11 +36,12 @@ class PageParser(HTMLParser): - """Collect outgoing hrefs and available anchor ids from one page.""" + """Collect outgoing hrefs, image srcs, and available anchor ids from one page.""" def __init__(self): super().__init__(convert_charrefs=True) self.hrefs: list[str] = [] + self.srcs: list[str] = [] self.ids: set[str] = set() def handle_starttag(self, tag, attrs): @@ -47,6 +53,8 @@ def handle_starttag(self, tag, attrs): self.ids.add(value) if tag == "a" and (href := attrs.get("href")): self.hrefs.append(href) + if tag == "img" and (src := attrs.get("src")): + self.srcs.append(src) def parse(path: Path) -> PageParser: @@ -83,7 +91,8 @@ def main() -> int: rel_page = page.relative_to(site) if str(rel_page).startswith(EXCLUDED_PREFIXES): continue - for href in doc.hrefs: + references = (*(("page", h) for h in doc.hrefs), *(("image", s) for s in doc.srcs)) + for kind, href in references: if EXTERNAL.match(href): continue path, fragment = urldefrag(href) @@ -95,15 +104,18 @@ def main() -> int: ): continue if not target.is_file(): - failures.append(f"{rel_page}: '{href}' -> no such page") + failures.append(f"{rel_page}: '{href}' -> no such {kind}") continue - if fragment: + # A fragment names an anchor only on an HTML page. On anything else + # it is a selector hook -- svg_theme.py marks figure variants with + # #only-light / #only-dark -- and there is no document to look it up in. + if fragment and target.suffix == ".html": target_doc = parsed.get(target) or parse(target) if fragment not in target_doc.ids: failures.append(f"{rel_page}: '{href}' -> no anchor '#{fragment}'") if failures: - print(f"{len(failures)} broken internal link(s) in the built site:\n") + print(f"{len(failures)} broken internal reference(s) in the built site:\n") for failure in failures: print(f" {failure}") print( @@ -113,7 +125,7 @@ def main() -> int: ) return 1 - print(f"OK: internal links resolve across {len(pages)} built pages") + print(f"OK: internal links and images resolve across {len(pages)} built pages") return 0 diff --git a/scripts/svg_theme.py b/scripts/svg_theme.py index 998f19cb..8422a49c 100644 --- a/scripts/svg_theme.py +++ b/scripts/svg_theme.py @@ -34,13 +34,23 @@ ) IMG_TAG = re.compile(r"]*?>", re.IGNORECASE) -IMG_SRC = re.compile(r'\bsrc\s*=\s*"([^"]+?)"', re.IGNORECASE) +IMG_SRC = re.compile(r"""\bsrc\s*=\s*(["'])(.+?)\1""", re.IGNORECASE) -# Populated by on_files, consumed by on_post_page: the stems of the figures +# Figures are keyed by their path under ``images/`` rather than by bare stem, so +# that two figures of the same name in different subdirectories cannot collide. +IMAGES_PREFIX = re.compile(rf"(?:^|/){re.escape(IMAGES_SUBDIR)}/") + +# Populated by on_files, consumed by on_post_page: the keys of the figures # that actually carry a dark block. _themed: set = set() +def _image_key(path): + """Return a figure's path relative to the images directory, or None.""" + match = IMAGES_PREFIX.search(path) + return path[match.end() :] if match else None + + def _split_media_block(text): """Return (before, inner, after) around the dark block, or None.""" match = MEDIA_OPEN.search(text) @@ -76,7 +86,7 @@ def on_files(files, config): if not file.src_uri.endswith(".svg"): continue if MEDIA_OPEN.search(Path(file.abs_src_path).read_text(encoding="utf-8")): - _themed.add(Path(file.src_uri).stem) + _themed.add(file.src_uri[len(IMAGES_SUBDIR) + 1 :]) return files @@ -89,11 +99,17 @@ def rewrite(match): src = IMG_SRC.search(tag) if not src: return tag - path = src.group(1) - stem = Path(path.split("#")[0].split("?")[0]).stem - if not path.lower().endswith(".svg") or stem not in _themed: + path = src.group(2) + # Strip any query or fragment before testing and slicing. The variants + # carry their own #only-light / #only-dark marker, which the CSS keys on, + # so whatever the author wrote there cannot survive the rewrite anyway. + bare = path.split("#", 1)[0].split("?", 1)[0] + if not bare.lower().endswith(".svg"): + return tag + key = _image_key(bare) + if key is None or key not in _themed: return tag - base = path[: -len(".svg")] + base = bare[: -len(".svg")] light = IMG_SRC.sub(lambda _: f'src="{base}.light.svg#only-light"', tag, count=1) dark = IMG_SRC.sub(lambda _: f'src="{base}.dark.svg#only-dark"', tag, count=1) return light + dark @@ -105,7 +121,7 @@ def on_post_build(config): images = Path(config["site_dir"]) / IMAGES_SUBDIR if not images.is_dir(): return - for svg in sorted(images.glob("*.svg")): + for svg in sorted(images.rglob("*.svg")): if svg.name.endswith((".light.svg", ".dark.svg")): continue pair = variants(svg.read_text(encoding="utf-8")) diff --git a/src/images/pipeline.svg b/src/images/pipeline.svg index e4ef0c90..6dcd26dc 100644 --- a/src/images/pipeline.svg +++ b/src/images/pipeline.svg @@ -6,13 +6,7 @@ prefers-color-scheme: dark. Colors are unique per role, so attribute selectors override the inline presentation attributes unambiguously; the three whites (page, code panel, part fill) are disambiguated by - class since they map to different darks. - - On docs.datajoint.com the media query is resolved at build time by - scripts/svg_theme.py, which splits this file into a light and a dark - variant so the figure follows the site's palette toggle rather than the - reader's OS setting. The media query still governs everywhere else the - file is viewed directly (GitHub, a raw link, a README). --> + class since they map to different darks. -->