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:
parent
03fb98afc4
commit
9783199691
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"}>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user