feat: add centralized fonts module for dynamic font loading
Create a DRY Fonts module that centralizes all font definitions and generates @font-face declarations dynamically based on typography preset. - Add Fonts module with typography font mappings - Generate @font-face CSS in CSSGenerator based on active preset - Add font preload links in shop_root layout for performance - Remove hardcoded font-face declarations from CSS This improves font loading performance by only loading fonts for the active typography preset and preloading critical fonts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2bc05097b9
commit
0ade34d994
@ -11,6 +11,7 @@ defmodule SimpleshopTheme.Theme.CSSGenerator do
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Settings.ThemeSettings
|
||||
alias SimpleshopTheme.Theme.Fonts
|
||||
|
||||
@doc """
|
||||
Generates CSS for theme settings.
|
||||
@ -18,9 +19,14 @@ defmodule SimpleshopTheme.Theme.CSSGenerator do
|
||||
Returns a string of CSS custom properties that can be injected into a <style> tag.
|
||||
This includes ALL theme tokens (mood, typography, shape, density) so the shop
|
||||
pages don't need the attribute-based CSS selectors.
|
||||
|
||||
Also includes @font-face declarations for the fonts used by the typography preset.
|
||||
"""
|
||||
def generate(%ThemeSettings{} = settings) do
|
||||
"""
|
||||
/* Font faces for #{settings.typography} typography */
|
||||
#{Fonts.generate_font_faces(settings.typography)}
|
||||
|
||||
/* Theme Tokens - Layer 2 (dynamically generated) */
|
||||
.themed {
|
||||
/* Mood colors */
|
||||
@ -113,73 +119,31 @@ defmodule SimpleshopTheme.Theme.CSSGenerator do
|
||||
# Fallback for any other mood value
|
||||
defp generate_mood(_), do: generate_mood("neutral")
|
||||
|
||||
# Typography style settings (weight and tracking per preset)
|
||||
@typography_styles %{
|
||||
"clean" => %{weight: 600, tracking: "-0.02em"},
|
||||
"editorial" => %{weight: 500, tracking: "-0.01em"},
|
||||
"modern" => %{weight: 500, tracking: "-0.03em"},
|
||||
"classic" => %{weight: 500, tracking: "0"},
|
||||
"friendly" => %{weight: 600, tracking: "-0.01em"},
|
||||
"minimal" => %{weight: 500, tracking: "0"},
|
||||
"impulse" => %{weight: 300, tracking: "0.02em"}
|
||||
}
|
||||
|
||||
# Typography - font families, weights, and tracking
|
||||
defp generate_typography("clean") do
|
||||
# Uses Fonts module for DRY font-family declarations
|
||||
defp generate_typography(typography) do
|
||||
%{heading: heading_key, body: body_key} = Fonts.fonts_for_typography(typography)
|
||||
style = Map.get(@typography_styles, typography, @typography_styles["clean"])
|
||||
|
||||
"""
|
||||
--t-font-heading: 'Manrope', system-ui, sans-serif;
|
||||
--t-font-body: 'Inter', system-ui, sans-serif;
|
||||
--t-heading-weight: 600;
|
||||
--t-heading-tracking: -0.02em;
|
||||
--t-font-heading: #{Fonts.font_family(heading_key)};
|
||||
--t-font-body: #{Fonts.font_family(body_key)};
|
||||
--t-heading-weight: #{style.weight};
|
||||
--t-heading-tracking: #{style.tracking};
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("editorial") do
|
||||
"""
|
||||
--t-font-heading: 'Playfair Display', Georgia, serif;
|
||||
--t-font-body: 'Raleway', system-ui, sans-serif;
|
||||
--t-heading-weight: 500;
|
||||
--t-heading-tracking: -0.01em;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("modern") do
|
||||
"""
|
||||
--t-font-heading: 'Space Grotesk', system-ui, sans-serif;
|
||||
--t-font-body: 'Inter', system-ui, sans-serif;
|
||||
--t-heading-weight: 500;
|
||||
--t-heading-tracking: -0.03em;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("classic") do
|
||||
"""
|
||||
--t-font-heading: 'Cormorant Garamond', Georgia, serif;
|
||||
--t-font-body: 'Source Serif 4', Georgia, serif;
|
||||
--t-heading-weight: 500;
|
||||
--t-heading-tracking: 0;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("friendly") do
|
||||
"""
|
||||
--t-font-heading: 'Fraunces', Georgia, serif;
|
||||
--t-font-body: 'Work Sans', system-ui, sans-serif;
|
||||
--t-heading-weight: 600;
|
||||
--t-heading-tracking: -0.01em;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("minimal") do
|
||||
"""
|
||||
--t-font-heading: 'DM Sans', system-ui, sans-serif;
|
||||
--t-font-body: 'Source Serif 4', Georgia, serif;
|
||||
--t-heading-weight: 500;
|
||||
--t-heading-tracking: 0;
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_typography("impulse") do
|
||||
"""
|
||||
--t-font-heading: 'Raleway', system-ui, sans-serif;
|
||||
--t-font-body: 'Inter', system-ui, sans-serif;
|
||||
--t-heading-weight: 300;
|
||||
--t-heading-tracking: 0.02em;
|
||||
"""
|
||||
end
|
||||
|
||||
# Fallback for any other typography value
|
||||
defp generate_typography(_), do: generate_typography("clean")
|
||||
|
||||
# Shape - border radii
|
||||
defp generate_shape("soft") do
|
||||
"""
|
||||
|
||||
214
lib/simpleshop_theme/theme/fonts.ex
Normal file
214
lib/simpleshop_theme/theme/fonts.ex
Normal file
@ -0,0 +1,214 @@
|
||||
defmodule SimpleshopTheme.Theme.Fonts do
|
||||
@moduledoc """
|
||||
Centralized font configuration for the theme system.
|
||||
|
||||
This module defines all available fonts and their variants, maps typography
|
||||
presets to font pairs, and generates CSS @font-face declarations.
|
||||
|
||||
Font files are expected to be in /priv/static/fonts/ and served at /fonts/.
|
||||
"""
|
||||
|
||||
# Font definitions: family name, file prefix, and available weights
|
||||
@fonts %{
|
||||
inter: %{
|
||||
family: "Inter",
|
||||
file_prefix: "inter-v20-latin",
|
||||
weights: [300, 400, 500, 600, 700],
|
||||
fallback: "system-ui, sans-serif"
|
||||
},
|
||||
manrope: %{
|
||||
family: "Manrope",
|
||||
file_prefix: "manrope-v20-latin",
|
||||
weights: [400, 500, 600, 700],
|
||||
fallback: "system-ui, sans-serif"
|
||||
},
|
||||
raleway: %{
|
||||
family: "Raleway",
|
||||
file_prefix: "raleway-v37-latin",
|
||||
weights: [300, 400, 500],
|
||||
fallback: "system-ui, sans-serif"
|
||||
},
|
||||
playfair: %{
|
||||
family: "Playfair Display",
|
||||
file_prefix: "playfair-display-v40-latin",
|
||||
weights: [400, 500, 700],
|
||||
fallback: "Georgia, serif"
|
||||
},
|
||||
space_grotesk: %{
|
||||
family: "Space Grotesk",
|
||||
file_prefix: "space-grotesk-v22-latin",
|
||||
weights: [400, 500, 600],
|
||||
fallback: "system-ui, sans-serif"
|
||||
},
|
||||
cormorant: %{
|
||||
family: "Cormorant Garamond",
|
||||
file_prefix: "cormorant-garamond-v21-latin",
|
||||
weights: [400, 500, 600],
|
||||
fallback: "Georgia, serif"
|
||||
},
|
||||
source_serif: %{
|
||||
family: "Source Serif 4",
|
||||
file_prefix: "source-serif-4-v14-latin",
|
||||
weights: [400, 600],
|
||||
fallback: "Georgia, serif"
|
||||
},
|
||||
fraunces: %{
|
||||
family: "Fraunces",
|
||||
file_prefix: "fraunces-v38-latin",
|
||||
weights: [400, 500, 600, 700],
|
||||
fallback: "Georgia, serif"
|
||||
},
|
||||
work_sans: %{
|
||||
family: "Work Sans",
|
||||
file_prefix: "work-sans-v24-latin",
|
||||
weights: [300, 400, 500, 600],
|
||||
fallback: "system-ui, sans-serif"
|
||||
},
|
||||
dm_sans: %{
|
||||
family: "DM Sans",
|
||||
file_prefix: "dm-sans-v17-latin",
|
||||
weights: [400, 500, 600, 700],
|
||||
fallback: "system-ui, sans-serif"
|
||||
}
|
||||
}
|
||||
|
||||
# Typography presets map to heading and body font keys
|
||||
@typography_fonts %{
|
||||
"clean" => %{heading: :manrope, body: :inter},
|
||||
"editorial" => %{heading: :playfair, body: :raleway},
|
||||
"modern" => %{heading: :space_grotesk, body: :inter},
|
||||
"classic" => %{heading: :cormorant, body: :source_serif},
|
||||
"friendly" => %{heading: :fraunces, body: :work_sans},
|
||||
"minimal" => %{heading: :dm_sans, body: :source_serif},
|
||||
"impulse" => %{heading: :raleway, body: :inter}
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns the font configuration for a given font key.
|
||||
"""
|
||||
def get_font(key), do: Map.get(@fonts, key)
|
||||
|
||||
@doc """
|
||||
Returns all font configurations.
|
||||
"""
|
||||
def all_fonts, do: @fonts
|
||||
|
||||
@doc """
|
||||
Returns the heading and body font keys for a typography preset.
|
||||
"""
|
||||
def fonts_for_typography(typography) do
|
||||
Map.get(@typography_fonts, typography, @typography_fonts["clean"])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the CSS font-family declaration (with fallbacks) for a font key.
|
||||
"""
|
||||
def font_family(key) do
|
||||
case get_font(key) do
|
||||
%{family: family, fallback: fallback} -> "'#{family}', #{fallback}"
|
||||
nil -> "system-ui, sans-serif"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates @font-face CSS for a specific typography preset.
|
||||
|
||||
Only includes the fonts needed for that preset.
|
||||
"""
|
||||
def generate_font_faces(typography) do
|
||||
%{heading: heading_key, body: body_key} = fonts_for_typography(typography)
|
||||
|
||||
font_keys =
|
||||
if heading_key == body_key do
|
||||
[heading_key]
|
||||
else
|
||||
[heading_key, body_key]
|
||||
end
|
||||
|
||||
font_keys
|
||||
|> Enum.map(&generate_font_face_for_font/1)
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates @font-face CSS for ALL fonts.
|
||||
|
||||
Used in the theme editor where users can switch between typography presets.
|
||||
"""
|
||||
def generate_all_font_faces do
|
||||
@fonts
|
||||
|> Map.keys()
|
||||
|> Enum.map(&generate_font_face_for_font/1)
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates preload link tags for a specific typography preset.
|
||||
|
||||
Returns a list of maps with href, as, type, and crossorigin attributes.
|
||||
"""
|
||||
def preload_links(typography) do
|
||||
%{heading: heading_key, body: body_key} = fonts_for_typography(typography)
|
||||
|
||||
# Preload the most commonly used weights
|
||||
preload_weights = %{
|
||||
# For headings, preload the typical heading weight (500-600)
|
||||
heading: [500, 600],
|
||||
# For body, preload regular and semibold
|
||||
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)
|
||||
|
||||
# Deduplicate in case heading and body use the same font
|
||||
(heading_links ++ body_links) |> Enum.uniq_by(& &1.href)
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp generate_font_face_for_font(key) 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-face {
|
||||
font-family: '#{family}';
|
||||
font-style: normal;
|
||||
font-weight: #{weight};
|
||||
font-display: swap;
|
||||
src: url('/fonts/#{prefix}-#{weight_suffix}.woff2') format('woff2');
|
||||
}
|
||||
"""
|
||||
end)
|
||||
|> Enum.join("")
|
||||
|
||||
nil ->
|
||||
""
|
||||
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
|
||||
@ -6,10 +6,14 @@
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<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 />
|
||||
<% end %>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
|
||||
<script defer phx-track-static src={~p"/assets/js/app.js"}>
|
||||
</script>
|
||||
<!-- Generated theme CSS (only active values, not all variants) -->
|
||||
<!-- Generated theme CSS with @font-face declarations -->
|
||||
<style id="theme-css"><%= Phoenix.HTML.raw(@generated_css) %></style>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user