Skip to content
Merged
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
188 changes: 163 additions & 25 deletions plugins/img_hygiene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<img>`` 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
``<img src>`` 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 ``<PATH>/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 <img ...> start tag. [^>]* is the standard practical bound;
Expand All @@ -37,22 +70,126 @@
_IMG_TAG_RE = re.compile(r"<img\b[^>]*>", 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<q>["'])(?P<val>.*?)(?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 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


def _resolve_src_to_file(src, settings):
"""Map an <img src> (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):
Expand All @@ -62,7 +199,8 @@ def add_image_hygiene(content):
html = getattr(content, "_content", None)
if not html or "<img" not in html:
return
content._content = _IMG_TAG_RE.sub(_augment_img, html)
settings = getattr(content, "settings", None) or {}
content._content = _IMG_TAG_RE.sub(_make_augmenter(settings), html)


def register():
Expand Down
Loading