Current section
Files
Jump to
Current section
Files
lib/sayfa/hooks/responsive_images.ex
defmodule Sayfa.Hooks.ResponsiveImages do
@moduledoc """
After-render hook that rewrites `<img>` tags to `<picture>` elements.
When enabled, every locally-sourced JPEG or PNG `<img>` tag produced by
Markdown rendering is wrapped in a `<picture>` block with `<source>` entries
for WebP and AVIF variants:
<!-- before -->
<img src="/images/hero.jpg" alt="Hero">
<!-- after -->
<picture>
<source srcset="/images/hero.avif" type="image/avif">
<source srcset="/images/hero.webp" type="image/webp">
<img src="/images/hero.jpg" alt="Hero">
</picture>
## Opt-in
Register the hook in your site's `config/config.exs`:
config :sayfa, :hooks, [Sayfa.Hooks.ResponsiveImages]
## Requirements
The `.webp` and `.avif` files must exist on disk for browsers to use them.
Run `bash scripts/optimize_images.sh` (generated by `mix sayfa.gen.images`)
before building to produce the variants.
External URLs (starting with `http://` or `https://`) and images already
inside a `<picture>` element are left unchanged.
"""
@behaviour Sayfa.Behaviours.Hook
# Matches existing <picture> blocks so we can protect them from rewriting.
@picture_re ~r/<picture>.*?<\/picture>/si
# Matches bare <img> tags with a local (starts with /) jpg/jpeg/png src.
@img_re ~r/<img\b([^>]*?)src="(\/[^"]+\.(jpe?g|png))"([^>]*)>/i
@impl true
@spec stage() :: :after_render
def stage, do: :after_render
@impl true
@spec run({Sayfa.Content.t(), String.t()}, map()) ::
{:ok, {Sayfa.Content.t(), String.t()}} | {:error, term()}
def run({content, html}, _opts) do
{:ok, {content, transform(html)}}
end
# ---- private helpers -------------------------------------------------------
defp transform(html) do
{protected_html, placeholders} = protect_pictures(html)
rewritten = rewrite_img_tags(protected_html)
restore_pictures(rewritten, placeholders)
end
defp protect_pictures(html) do
Regex.scan(@picture_re, html)
|> Enum.map(fn [match | _] -> match end)
|> Enum.with_index()
|> Enum.reduce({html, %{}}, fn {match, i}, {acc_html, acc_placeholders} ->
placeholder = "SAYFA_PICTURE_PLACEHOLDER_#{i}"
{String.replace(acc_html, match, placeholder, global: false),
Map.put(acc_placeholders, placeholder, match)}
end)
end
defp rewrite_img_tags(html) do
Regex.replace(@img_re, html, fn _full, before_src, src, _ext, after_src ->
base = Regex.replace(~r/\.[^.]+$/, src, "")
attrs = String.trim(before_src <> after_src)
img_tag =
if attrs == "",
do: ~s(<img src="#{src}">),
else: ~s(<img src="#{src}" #{attrs}>)
"<picture>" <>
~s(<source srcset="#{base}.avif" type="image/avif">) <>
~s(<source srcset="#{base}.webp" type="image/webp">) <>
img_tag <>
"</picture>"
end)
end
defp restore_pictures(html, placeholders) do
Enum.reduce(placeholders, html, fn {placeholder, original}, acc ->
String.replace(acc, placeholder, original)
end)
end
end