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)},
# Image variant cache - ensures all variants exist on startup
SimpleshopTheme.Images.VariantCache,
# Theme CSS cache
SimpleshopTheme.Theme.CSSCache,
# Start to serve requests, typically the last entry
SimpleshopThemeWeb.Endpoint
# Start to serve requests
SimpleshopThemeWeb.Endpoint,
# Theme CSS cache - must start after Endpoint for static_path/1 to work
SimpleshopTheme.Theme.CSSCache
]
# See https://hexdocs.pm/elixir/Supervisor.html

View File

@ -82,7 +82,11 @@ defmodule SimpleshopTheme.Theme.CSSCache do
alias SimpleshopTheme.Theme.CSSGenerator
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)
:ok
end

View File

@ -21,11 +21,14 @@ defmodule SimpleshopTheme.Theme.CSSGenerator do
pages don't need the attribute-based CSS selectors.
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 */
#{Fonts.generate_font_faces(settings.typography)}
#{Fonts.generate_font_faces(settings.typography, path_resolver)}
/* Theme Tokens - Layer 2 (dynamically generated) */
.themed {

View File

@ -114,8 +114,11 @@ defmodule SimpleshopTheme.Theme.Fonts do
Generates @font-face CSS for a specific typography 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)
font_keys =
@ -126,7 +129,7 @@ defmodule SimpleshopTheme.Theme.Fonts do
end
font_keys
|> Enum.map(&generate_font_face_for_font/1)
|> Enum.map(&generate_font_face_for_font(&1, path_resolver))
|> Enum.join("\n")
end
@ -134,20 +137,22 @@ defmodule SimpleshopTheme.Theme.Fonts do
Generates @font-face CSS for ALL fonts.
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
|> Map.keys()
|> Enum.map(&generate_font_face_for_font/1)
|> Enum.map(&generate_font_face_for_font(&1, path_resolver))
|> Enum.join("\n")
end
@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)
# Preload the most commonly used weights
@ -158,21 +163,60 @@ defmodule SimpleshopTheme.Theme.Fonts do
body: [400, 600]
}
heading_links = generate_preload_links_for_font(heading_key, preload_weights.heading)
body_links = generate_preload_links_for_font(body_key, preload_weights.body)
heading_paths = font_paths_for_weights(heading_key, preload_weights.heading)
body_paths = font_paths_for_weights(body_key, preload_weights.body)
# 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
# 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
%{family: family, file_prefix: prefix, weights: weights} ->
weights
|> Enum.map(fn weight ->
weight_suffix = if weight == 400, do: "regular", else: to_string(weight)
font_path = path_resolver.("/fonts/#{prefix}-#{weight_suffix}.woff2")
"""
@font-face {
@ -180,7 +224,7 @@ defmodule SimpleshopTheme.Theme.Fonts do
font-style: normal;
font-weight: #{weight};
font-display: swap;
src: url('/fonts/#{prefix}-#{weight_suffix}.woff2') format('woff2');
src: url('#{font_path}') format('woff2');
}
"""
end)
@ -191,24 +235,4 @@ defmodule SimpleshopTheme.Theme.Fonts do
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

View File

@ -7,8 +7,11 @@
<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>
<!-- Preload critical fonts for the current typography preset -->
<%= for preload <- SimpleshopTheme.Theme.Fonts.preload_links(@theme_settings.typography) do %>
<link rel="preload" href={preload.href} as={preload.as} type={preload.type} crossorigin />
<%= for preload <- SimpleshopTheme.Theme.Fonts.preload_links(
@theme_settings.typography,
&SimpleshopThemeWeb.Endpoint.static_path/1
) do %>
<link rel="preload" href={preload.href} as="font" type="font/woff2" crossorigin />
<% end %>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<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),
# the `gzip` option is enabled to serve compressed
# 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,
at: "/",
from: :simpleshop_theme,
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
plug Tidewave