From 31c93ffcfd622495e50b21f4947e7c40e6465e0f Mon Sep 17 00:00:00 2001 From: rivassec Date: Fri, 11 Sep 2026 20:12:44 -0700 Subject: [PATCH 1/2] Perf: inject width/height on inline images to prevent CLS Extend img_hygiene to add real pixel width/height attributes to inline content images, so the browser reserves layout space before image bytes arrive and cumulative layout shift (CLS) is eliminated. The plugin maps each back to its source file under the content tree (handling {static} markers, relative ./images paths, and absolute site URLs alike), reads the dimensions with Pillow, and emits them as plain width/height HTML attributes. Remote hosts and data:/blob: URIs are skipped; imgs that already carry width or height are left untouched, as are existing loading/decoding attributes. Dimensions are plain attributes, never a style="" attribute, so the strict-CSP audit stays green. custom.css sets height:auto on content images, so injected dimensions drive aspect-ratio reservation without distorting the rendered image. --- plugins/img_hygiene.py | 185 +++++++++++++++++++++++++++++++++++------ 1 file changed, 160 insertions(+), 25 deletions(-) diff --git a/plugins/img_hygiene.py b/plugins/img_hygiene.py index 5d97614c..3111f8a2 100644 --- a/plugins/img_hygiene.py +++ b/plugins/img_hygiene.py @@ -10,25 +10,58 @@ * ``loading="lazy"`` - defer offscreen images until they are near the viewport * ``decoding="async"`` - let the browser decode the image off the main thread +* ``width`` / ``height`` - the image's real pixel dimensions, read from the + source file on disk, so the browser can reserve layout space before the + bytes arrive and avoid cumulative layout shift (CLS). Attributes are only added when absent, so author-specified values (e.g. an -eager hero image with ``loading="eager"``) are preserved. The transform is a -conservative regex over the content HTML - it never touches ``src``/``href`` -values, so Pelican's ``{static}``/``{filename}`` link resolution (which runs -lazily on ``.content`` access) is unaffected. - -Width/height injection is intentionally NOT performed: it would require -resolving each ``{static}`` path back to a local file and reading its -dimensions with PIL, which is not straightforward given intra-site link -markers are unresolved at ``content_object_init`` time. Lazy-loading plus -async decoding are the high-value, zero-risk wins. +eager hero image with ``loading="eager"``, or an ```` that already carries +its own ``width``/``height``) are preserved. + +Why width/height is safe here +----------------------------- +``content/static/custom.css`` styles content images with:: + + main article p img { aspect-ratio: auto; height: auto; max-width: 100%; } + +``height: auto`` (an author-stylesheet rule) beats the HTML ``height`` +presentational attribute, so the injected dimensions never distort the image: +the browser derives the intrinsic aspect ratio from ``width``/``height`` and +scales by width. ``aspect-ratio: auto`` explicitly tells the browser to honour +those attributes for space reservation, which is exactly what kills CLS. + +The dimensions are emitted as plain HTML attributes (``width="800"``), never a +``style=""`` attribute, so the strict-CSP audit (scripts/csp_audit.py) stays +green. + +Resolving src -> file on disk +----------------------------- +``width``/``height`` require reading the source image, so the plugin maps each +```` back to a file under the content tree. At +``content_object_init`` the ``src`` is still an intra-site marker +(``{static}/images/foo.webp``); after Pelican resolves links it is a site URL +(``/images/foo.webp`` or ``https://rivassec.com/images/foo.webp``). Both forms +carry the same site-relative path, so ``_resolve_src_to_file`` strips any +marker, scheme, or host and maps the ``/images/...`` (or any ``STATIC_PATHS`` +directory) tail onto ``/images/...`` on disk. Remote hosts and +``data:``/``blob:`` URIs are skipped - there is no local file to measure. + +The transform is a conservative regex over the content HTML; it never rewrites +``src``/``href`` values, so Pelican's ``{static}``/``{filename}`` link +resolution (which runs lazily on ``.content`` access) is unaffected. """ +import os import re import logging from pelican import signals, contents +try: # Pillow is a build dependency; degrade gracefully if it is ever absent. + from PIL import Image +except Exception: # pragma: no cover - only hit if Pillow is missing + Image = None + logger = logging.getLogger(__name__) # Match a single start tag. [^>]* is the standard practical bound; @@ -37,22 +70,123 @@ _IMG_TAG_RE = re.compile(r"]*>", re.IGNORECASE) _HAS_LOADING_RE = re.compile(r"\bloading\s*=", re.IGNORECASE) _HAS_DECODING_RE = re.compile(r"\bdecoding\s*=", re.IGNORECASE) +_HAS_WIDTH_RE = re.compile(r"\bwidth\s*=", re.IGNORECASE) +_HAS_HEIGHT_RE = re.compile(r"\bheight\s*=", re.IGNORECASE) +_SRC_RE = re.compile(r"""\bsrc\s*=\s*(?P["'])(?P.*?)(?P=q)""", re.IGNORECASE) +# Intra-site link markers Pelican leaves in _content before .content resolves. +_MARKER_RE = re.compile(r"^\{(?:static|filename|attach)\}", re.IGNORECASE) + +# filepath -> (width, height) | None. Process-lifetime cache so an image +# referenced from several posts is only opened once per build. +_DIM_CACHE = {} + + +def _dimensions(filepath): + """Return (width, height) for a local image file, or None on any failure.""" + if filepath in _DIM_CACHE: + return _DIM_CACHE[filepath] + result = None + if Image is not None: + try: + with Image.open(filepath) as im: + result = im.size # (width, height) + except (OSError, ValueError) as exc: + logger.debug("img_hygiene: cannot read dimensions of %s: %s", filepath, exc) + _DIM_CACHE[filepath] = result + return result + + +def _resolve_src_to_file(src, settings): + """Map an (marker or resolved URL) to a source file on disk. + + Returns an absolute/relative filesystem path if the src points at a local + static asset that exists, else None (remote, data URI, or not found). + """ + src = src.strip() + if not src: + return None + + # Strip a leading intra-site marker ({static}/{filename}/{attach}); the + # remainder is a site-relative path such as /images/foo.webp. + marker = _MARKER_RE.match(src) + if marker: + path = src[marker.end():] + else: + lower = src.lower() + if lower.startswith(("data:", "blob:")): + return None + if "://" in src: + # Absolute URL. Only our own site can be mapped to disk; anything + # else is a remote image with no local file to measure. + scheme, _, rest = src.partition("://") + if scheme.lower() not in ("http", "https"): + return None + host, _, tail = rest.partition("/") + siteurl = (settings.get("SITEURL") or "") if settings else "" + local_hosts = {"rivassec.com", "www.rivassec.com"} + if "://" in siteurl: + local_hosts.add(siteurl.split("://", 1)[1].split("/", 1)[0]) + if host not in local_hosts: + return None + path = "/" + tail + else: + # Site-relative or document-relative path (e.g. /images/x.webp or + # ../images/x.webp). + path = src + + # Drop any query string or fragment. + path = path.split("?", 1)[0].split("#", 1)[0] + + content_root = settings.get("PATH", "content") if settings else "content" + static_dirs = settings.get("STATIC_PATHS", ["images"]) if settings else ["images"] + + # Locate the static directory segment (e.g. "images/") within the path and + # rebuild the path relative to the content root. Handles /images/x.webp, + # ../images/x.webp, {static}/images/x.webp alike. + for static_dir in static_dirs: + needle = static_dir.strip("/") + "/" + idx = path.rfind("/" + needle) + if idx != -1: + rel = path[idx + 1:] + elif path.startswith(needle): + rel = path + else: + continue + candidate = os.path.join(content_root, rel) + if os.path.isfile(candidate): + return candidate + return None + + +def _make_augmenter(settings): + def _augment_img(match): + tag = match.group(0) + additions = "" + if not _HAS_LOADING_RE.search(tag): + additions += ' loading="lazy"' + if not _HAS_DECODING_RE.search(tag): + additions += ' decoding="async"' + + # Only inject dimensions when the tag has neither width nor height, so + # author-specified sizing is always respected. + if not _HAS_WIDTH_RE.search(tag) and not _HAS_HEIGHT_RE.search(tag): + src_match = _SRC_RE.search(tag) + if src_match: + filepath = _resolve_src_to_file(src_match.group("val"), settings) + if filepath: + dims = _dimensions(filepath) + if dims: + additions += ' width="%d" height="%d"' % (dims[0], dims[1]) + if not additions: + return tag + # Insert new attributes just before the tag close, preserving whether + # the original tag was self-closing ("/>") or a plain start tag (">"). + if tag.endswith("/>"): + return tag[:-2].rstrip() + additions + " />" + return tag[:-1] + additions + ">" -def _augment_img(match): - tag = match.group(0) - additions = "" - if not _HAS_LOADING_RE.search(tag): - additions += ' loading="lazy"' - if not _HAS_DECODING_RE.search(tag): - additions += ' decoding="async"' - if not additions: - return tag - # Insert new attributes just before the tag close, preserving whether the - # original tag was self-closing ("/>") or a plain HTML5 start tag (">"). - if tag.endswith("/>"): - return tag[:-2].rstrip() + additions + " />" - return tag[:-1] + additions + ">" + return _augment_img def add_image_hygiene(content): @@ -62,7 +196,8 @@ def add_image_hygiene(content): html = getattr(content, "_content", None) if not html or " Date: Fri, 11 Sep 2026 20:18:49 -0700 Subject: [PATCH 2/2] img_hygiene: broaden dimension-read except to Exception (build-safety) Pillow's DecompressionBombError subclasses Exception, not OSError, so a future oversized inline image would break the build instead of degrading to no-dimensions. Dimension injection is best-effort; never fatal. (Critic follow-up on the CLS PR.) --- plugins/img_hygiene.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/img_hygiene.py b/plugins/img_hygiene.py index 3111f8a2..247ef710 100644 --- a/plugins/img_hygiene.py +++ b/plugins/img_hygiene.py @@ -90,7 +90,10 @@ def _dimensions(filepath): try: with Image.open(filepath) as im: result = im.size # (width, height) - except (OSError, ValueError) as exc: + except Exception as exc: # noqa: BLE001 - never let image reads break the build + # Broad on purpose: Pillow raises DecompressionBombError (subclasses + # Exception, not OSError) on very large images; dimension injection + # is best-effort and must degrade to "no dims", never fail the build. logger.debug("img_hygiene: cannot read dimensions of %s: %s", filepath, exc) _DIM_CACHE[filepath] = result return result