Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions scripts/check_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<a href>`` 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 ``<a href>`` and ``<img src>`` must
resolve to a real file in the build, and any fragment on an HTML target must
match an ``id`` on that page.

Checking ``<img src>`` 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]
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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


Expand Down
32 changes: 24 additions & 8 deletions scripts/svg_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,23 @@
)

IMG_TAG = re.compile(r"<img\b[^>]*?>", 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)
Expand Down Expand Up @@ -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


Expand All @@ -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
Expand All @@ -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"))
Expand Down
8 changes: 1 addition & 7 deletions src/images/pipeline.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading