perf: use digested font paths in CSS and preloads

Add path_resolver parameter to font generation functions so both
font preloads and CSS @font-face declarations use the same digested
paths in production. This prevents duplicate font downloads when
preloads were using digested paths but CSS used non-digested paths.

- Add path_resolver parameter to Fonts.generate_font_faces/2,
  Fonts.preload_links/2, and Fonts.generate_all_font_faces/1
- Update CSSGenerator.generate/2 to accept path_resolver
- Update CSSCache.warm/0 to use Endpoint.static_path/1
- Move CSSCache to start after Endpoint in supervision tree
- Add 1-year cache headers for static assets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-25 09:32:06 +00:00
parent 03fb98afc4
commit 9783199691
6 changed files with 81 additions and 42 deletions

View File

@ -18,10 +18,10 @@ defmodule SimpleshopTheme.Application do
{Oban, Application.fetch_env!(:simpleshop_theme, Oban)}, {Oban, Application.fetch_env!(:simpleshop_theme, Oban)},
# Image variant cache - ensures all variants exist on startup # Image variant cache - ensures all variants exist on startup
SimpleshopTheme.Images.VariantCache, SimpleshopTheme.Images.VariantCache,
# Theme CSS cache # Start to serve requests
SimpleshopTheme.Theme.CSSCache, SimpleshopThemeWeb.Endpoint,
# Start to serve requests, typically the last entry # Theme CSS cache - must start after Endpoint for static_path/1 to work
SimpleshopThemeWeb.Endpoint SimpleshopTheme.Theme.CSSCache
] ]
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html

View File

@ -82,7 +82,11 @@ defmodule SimpleshopTheme.Theme.CSSCache do
alias SimpleshopTheme.Theme.CSSGenerator alias SimpleshopTheme.Theme.CSSGenerator
settings = Settings.get_theme_settings() settings = Settings.get_theme_settings()
css = CSSGenerator.generate(settings)
# Use endpoint's static_path for digested URLs in production
path_resolver = &SimpleshopThemeWeb.Endpoint.static_path/1
css = CSSGenerator.generate(settings, path_resolver)
put(css) put(css)
:ok :ok
end end

View File

@ -21,11 +21,14 @@ defmodule SimpleshopTheme.Theme.CSSGenerator do
pages don't need the attribute-based CSS selectors. pages don't need the attribute-based CSS selectors.
Also includes @font-face declarations for the fonts used by the typography preset. Also includes @font-face declarations for the fonts used by the typography preset.
Accepts an optional path_resolver function for digested font paths.
In production, pass `&SimpleshopThemeWeb.Endpoint.static_path/1`.
""" """
def generate(%ThemeSettings{} = settings) do def generate(%ThemeSettings{} = settings, path_resolver \\ fn path -> path end) do
""" """
/* Font faces for #{settings.typography} typography */ /* Font faces for #{settings.typography} typography */
#{Fonts.generate_font_faces(settings.typography)} #{Fonts.generate_font_faces(settings.typography, path_resolver)}
/* Theme Tokens - Layer 2 (dynamically generated) */ /* Theme Tokens - Layer 2 (dynamically generated) */
.themed { .themed {

View File

@ -114,8 +114,11 @@ defmodule SimpleshopTheme.Theme.Fonts do
Generates @font-face CSS for a specific typography preset. Generates @font-face CSS for a specific typography preset.
Only includes the fonts needed for that preset. Only includes the fonts needed for that preset.
Accepts an optional path_resolver function to transform font URLs.
In production, pass `&SimpleshopThemeWeb.Endpoint.static_path/1` for digested paths.
""" """
def generate_font_faces(typography) do def generate_font_faces(typography, path_resolver \\ &default_path_resolver/1) do
%{heading: heading_key, body: body_key} = fonts_for_typography(typography) %{heading: heading_key, body: body_key} = fonts_for_typography(typography)
font_keys = font_keys =
@ -126,7 +129,7 @@ defmodule SimpleshopTheme.Theme.Fonts do
end end
font_keys font_keys
|> Enum.map(&generate_font_face_for_font/1) |> Enum.map(&generate_font_face_for_font(&1, path_resolver))
|> Enum.join("\n") |> Enum.join("\n")
end end
@ -134,20 +137,22 @@ defmodule SimpleshopTheme.Theme.Fonts do
Generates @font-face CSS for ALL fonts. Generates @font-face CSS for ALL fonts.
Used in the theme editor where users can switch between typography presets. Used in the theme editor where users can switch between typography presets.
Accepts an optional path_resolver function for digested paths.
""" """
def generate_all_font_faces do def generate_all_font_faces(path_resolver \\ &default_path_resolver/1) do
@fonts @fonts
|> Map.keys() |> Map.keys()
|> Enum.map(&generate_font_face_for_font/1) |> Enum.map(&generate_font_face_for_font(&1, path_resolver))
|> Enum.join("\n") |> Enum.join("\n")
end end
@doc """ @doc """
Generates preload link tags for a specific typography preset. Returns the font file paths (without /fonts prefix) for a typography preset.
Returns a list of maps with href, as, type, and crossorigin attributes. Used by templates to generate preload links with digested paths via ~p sigil.
""" """
def preload_links(typography) do def preload_font_paths(typography) do
%{heading: heading_key, body: body_key} = fonts_for_typography(typography) %{heading: heading_key, body: body_key} = fonts_for_typography(typography)
# Preload the most commonly used weights # Preload the most commonly used weights
@ -158,21 +163,60 @@ defmodule SimpleshopTheme.Theme.Fonts do
body: [400, 600] body: [400, 600]
} }
heading_links = generate_preload_links_for_font(heading_key, preload_weights.heading) heading_paths = font_paths_for_weights(heading_key, preload_weights.heading)
body_links = generate_preload_links_for_font(body_key, preload_weights.body) body_paths = font_paths_for_weights(body_key, preload_weights.body)
# Deduplicate in case heading and body use the same font # Deduplicate in case heading and body use the same font
(heading_links ++ body_links) |> Enum.uniq_by(& &1.href) (heading_paths ++ body_paths) |> Enum.uniq()
end
defp font_paths_for_weights(key, weights) do
case get_font(key) do
%{file_prefix: prefix, weights: available_weights} ->
weights
|> Enum.filter(&(&1 in available_weights))
|> Enum.map(fn weight ->
weight_suffix = if weight == 400, do: "regular", else: to_string(weight)
"#{prefix}-#{weight_suffix}.woff2"
end)
nil ->
[]
end
end
@doc """
Generates preload link tags for a specific typography preset.
Returns a list of maps with href, as, type, and crossorigin attributes.
Accepts an optional path_resolver function for digested paths.
In production, pass `&SimpleshopThemeWeb.Endpoint.static_path/1`.
"""
def preload_links(typography, path_resolver \\ &default_path_resolver/1) do
typography
|> preload_font_paths()
|> Enum.map(fn filename ->
%{
href: path_resolver.("/fonts/#{filename}"),
as: "font",
type: "font/woff2",
crossorigin: true
}
end)
end end
# Private functions # Private functions
defp generate_font_face_for_font(key) do defp default_path_resolver(path), do: path
defp generate_font_face_for_font(key, path_resolver) do
case get_font(key) do case get_font(key) do
%{family: family, file_prefix: prefix, weights: weights} -> %{family: family, file_prefix: prefix, weights: weights} ->
weights weights
|> Enum.map(fn weight -> |> Enum.map(fn weight ->
weight_suffix = if weight == 400, do: "regular", else: to_string(weight) weight_suffix = if weight == 400, do: "regular", else: to_string(weight)
font_path = path_resolver.("/fonts/#{prefix}-#{weight_suffix}.woff2")
""" """
@font-face { @font-face {
@ -180,7 +224,7 @@ defmodule SimpleshopTheme.Theme.Fonts do
font-style: normal; font-style: normal;
font-weight: #{weight}; font-weight: #{weight};
font-display: swap; font-display: swap;
src: url('/fonts/#{prefix}-#{weight_suffix}.woff2') format('woff2'); src: url('#{font_path}') format('woff2');
} }
""" """
end) end)
@ -191,24 +235,4 @@ defmodule SimpleshopTheme.Theme.Fonts do
end end
end end
defp generate_preload_links_for_font(key, weights) do
case get_font(key) do
%{file_prefix: prefix, weights: available_weights} ->
weights
|> Enum.filter(&(&1 in available_weights))
|> Enum.map(fn weight ->
weight_suffix = if weight == 400, do: "regular", else: to_string(weight)
%{
href: "/fonts/#{prefix}-#{weight_suffix}.woff2",
as: "font",
type: "font/woff2",
crossorigin: true
}
end)
nil ->
[]
end
end
end end

View File

@ -7,8 +7,11 @@
<meta name="description" content={assigns[:page_description] || @theme_settings.site_description || "Welcome to #{@theme_settings.site_name}"} /> <meta name="description" content={assigns[:page_description] || @theme_settings.site_description || "Welcome to #{@theme_settings.site_name}"} />
<.live_title><%= assigns[:page_title] || @theme_settings.site_name %></.live_title> <.live_title><%= assigns[:page_title] || @theme_settings.site_name %></.live_title>
<!-- Preload critical fonts for the current typography preset --> <!-- Preload critical fonts for the current typography preset -->
<%= for preload <- SimpleshopTheme.Theme.Fonts.preload_links(@theme_settings.typography) do %> <%= for preload <- SimpleshopTheme.Theme.Fonts.preload_links(
<link rel="preload" href={preload.href} as={preload.as} type={preload.type} crossorigin /> @theme_settings.typography,
&SimpleshopThemeWeb.Endpoint.static_path/1
) do %>
<link rel="preload" href={preload.href} as="font" type="font/woff2" crossorigin />
<% end %> <% end %>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static src={~p"/assets/js/app.js"}> <script defer phx-track-static src={~p"/assets/js/app.js"}>

View File

@ -20,11 +20,16 @@ defmodule SimpleshopThemeWeb.Endpoint do
# When code reloading is disabled (e.g., in production), # When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed # the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`. # static files generated by running `phx.digest`.
#
# Cache headers: 1 year for all static assets. Digested assets (with hash
# in filename) use `immutable`. Fonts/mockups/images rarely change and
# benefit from aggressive caching.
plug Plug.Static, plug Plug.Static,
at: "/", at: "/",
from: :simpleshop_theme, from: :simpleshop_theme,
gzip: not code_reloading?, gzip: not code_reloading?,
only: SimpleshopThemeWeb.static_paths() only: SimpleshopThemeWeb.static_paths(),
cache_control_for_etags: "public, max-age=31536000, immutable"
if Code.ensure_loaded?(Tidewave) do if Code.ensure_loaded?(Tidewave) do
plug Tidewave plug Tidewave