diff --git a/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb b/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb index 01bb6eea36..f6ba9897b0 100644 --- a/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb +++ b/entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb @@ -43,9 +43,20 @@ def scrolled_theme_properties_style_tag(theme) end def scrolled_theme_font_face_rules(theme) - theme.options.fetch(:font_faces, []).filter_map { |face| - FontFaceRule.new(face, theme:) { |path| scrolled_theme_asset_path(theme, path) }.generate - }.join("\n") + scrolled_theme_font_faces(theme).filter_map(&:generate).join("\n") + end + + def scrolled_theme_font_preload_link_tags(theme) + safe_join( + scrolled_theme_font_faces(theme).filter_map(&:preload_source).map do |source| + tag.link(rel: 'preload', + as: 'font', + type: source[:type], + href: source[:url], + crossorigin: 'anonymous', + data: {theme: ''}) + end + ) end def scrolled_theme_typography_rules(theme) @@ -59,6 +70,12 @@ def scrolled_theme_properties_rules(theme) private + def scrolled_theme_font_faces(theme) + theme.options.fetch(:font_faces, []).map do |face| + FontFaceRule.new(face, theme:) { |path| scrolled_theme_asset_path(theme, path) } + end + end + # @api private class FontFaceRule FORMATS = { @@ -68,6 +85,13 @@ class FontFaceRule '.otf' => 'opentype' }.freeze + MIME_TYPES = { + 'woff2' => 'font/woff2', + 'woff' => 'font/woff', + 'truetype' => 'font/ttf', + 'opentype' => 'font/otf' + }.freeze + WEIGHT_PATTERN = /\A(normal|bold|\d{1,4}( \d{1,4})?)\z/ STYLE_PATTERN = /\A(normal|italic)\z/ FORMAT_PATTERN = /\A(woff2?|truetype|opentype|embedded-opentype|svg)(-variations)?\z/ @@ -88,7 +112,7 @@ def initialize(face, theme:, &resolve_path) end def generate - return if family.blank? || source_values.empty? + return unless valid? <<~CSS @font-face { @@ -97,10 +121,24 @@ def generate CSS end + # Only the first source is preloaded since the browser downloads + # exactly one of the alternative formats. + def preload_source + return unless valid? && face[:preload] + + source = sources_with_safe_urls.first + + {url: source[:url], type: mime_type(source)} + end + private attr_reader :face, :theme + def valid? + family.present? && sources_with_safe_urls.any? + end + def declarations [ %(font-family: "#{family}";), @@ -115,10 +153,11 @@ def family end def source_values - @source_values ||= - sources - .reject { |source| source[:url].match?(UNSAFE_IN_URL) } - .map { |source| source_value(source[:url], source[:format]) } + sources_with_safe_urls.map { |source| source_value(source) } + end + + def sources_with_safe_urls + @sources_with_safe_urls ||= sources.reject { |source| source[:url].match?(UNSAFE_IN_URL) } end def sources @@ -151,14 +190,22 @@ def resolve_url(url) @resolve_path.call(url).to_s end - def source_value(url, format) - format = [format, FORMATS[extension(url)]].find do |candidate| - candidate.to_s.match?(FORMAT_PATTERN) - end + def source_value(source) + format = source_format(source) - return %(url("#{url}")) unless format + return %(url("#{source[:url]}")) unless format - %(url("#{url}") format("#{format}")) + %(url("#{source[:url]}") format("#{format}")) + end + + def mime_type(source) + MIME_TYPES[source_format(source).to_s.delete_suffix('-variations')] + end + + def source_format(source) + [source[:format], FORMATS[extension(source[:url])]].find do |candidate| + candidate.to_s.match?(FORMAT_PATTERN) + end end def extension(url) diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb b/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb index e24b432827..95803242eb 100644 --- a/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb +++ b/entry_types/scrolled/app/views/pageflow_scrolled/entries/show.html.erb @@ -21,6 +21,7 @@ <%= scrolled_sprockets_asset_tags(entry, entry_mode: entry_mode) %> <%= scrolled_frontend_stylesheet_packs_tag(entry, entry_mode: entry_mode, seed_options: seed_options) %> + <%= scrolled_theme_font_preload_link_tags(entry.theme) %> <%= scrolled_theme_properties_style_tag(entry.theme) %> <%= scrolled_theme_stylesheet_pack_tags(entry.theme) %> diff --git a/entry_types/scrolled/doc/creating_themes/custom_typography.md b/entry_types/scrolled/doc/creating_themes/custom_typography.md index c62ac86643..0e3eac61e8 100644 --- a/entry_types/scrolled/doc/creating_themes/custom_typography.md +++ b/entry_types/scrolled/doc/creating_themes/custom_typography.md @@ -45,6 +45,7 @@ The following keys are supported: | `style` | Either `normal` or `italic`. | | `unicode_range` | Code points provided by the font file. See below. | | `file_role` | Role of an uploaded theme customization file to use instead of `src`. | +| `preload` | Pass `true` to let the browser start downloading the font file right away. See below. | `font-display: swap` is always included so that text remains visible while font files are loading. Faces with invalid values are skipped. @@ -82,6 +83,31 @@ specify formats per source: 'fonts/open-sans-400-normal.woff']} ``` +### Preloading Fonts + +Browsers only download a font file once they lay out text that uses +the font face. Mark the few faces that are needed for the first +screenful to have the file requested as early as possible: + +``` ruby +{family: 'Open Sans', + weight: '400', + src: 'fonts/open-sans-400-normal.woff2', + preload: true} +``` + +Published entries then contain a link tag in the head: + +``` html + +``` + +Only the first source of the face is preloaded since the browser +downloads exactly one of the alternative formats. Preloading more +fonts than the entry displays right away delays other resources - only +mark faces that are used above the fold. + ### Reducing Font File Size Fonts that support many scripts can be split into subsets. Declare one diff --git a/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb b/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb index ada62bb3b4..74301e6bee 100644 --- a/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb +++ b/entry_types/scrolled/spec/helpers/pageflow_scrolled/themes_helper_spec.rb @@ -452,6 +452,138 @@ def upload_font(entry, file_name) end end + describe '#scrolled_theme_font_preload_link_tags' do + before do + allow(helper).to receive(:asset_pack_path) { |path| "/packs/#{path}" } + end + + it 'renders link tag for face marked for preloading' do + theme = Pageflow::Theme.new(:test, + font_faces: [ + {family: 'Avenir', src: '/fonts/a.woff2', preload: true} + ]) + + html = helper.scrolled_theme_font_preload_link_tags(theme) + + expect(html).to have_css('link[data-theme][rel="preload"][as="font"]' \ + '[href="/fonts/a.woff2"][type="font/woff2"]' \ + '[crossorigin="anonymous"]', + visible: false) + end + + it 'skips faces not marked for preloading' do + theme = Pageflow::Theme.new(:test, + font_faces: [ + {family: 'Avenir', src: '/fonts/a.woff2'}, + {family: 'Oswald', src: '/fonts/b.woff2', preload: false} + ]) + + html = helper.scrolled_theme_font_preload_link_tags(theme) + + expect(html).to be_blank + end + + it 'only preloads first source of face' do + theme = Pageflow::Theme.new(:test, + font_faces: [ + {family: 'Avenir', + src: ['/fonts/a.woff2', '/fonts/a.woff'], + preload: true} + ]) + + html = helper.scrolled_theme_font_preload_link_tags(theme) + + expect(html).to have_css('link[href="/fonts/a.woff2"]', visible: false) + expect(html).not_to have_css('link[href="/fonts/a.woff"]', visible: false) + end + + it 'resolves relative src in theme directory' do + theme = Pageflow::CustomizedTheme.find( + entry: create(:entry), + theme: Pageflow::Theme.new(:test, + font_faces: [ + {family: 'Avenir', src: 'fonts/a.woff2', preload: true} + ]) + ) + + html = helper.scrolled_theme_font_preload_link_tags(theme) + + expect(html).to have_css( + 'link[href="/packs/static/pageflow-scrolled/themes/test/fonts/a.woff2"]', + visible: false + ) + end + + it 'derives type from format of source' do + theme = Pageflow::Theme.new(:test, + font_faces: [ + {family: 'Avenir', + src: '/fonts/a.woff2', + format: 'woff2-variations', + preload: true} + ]) + + html = helper.scrolled_theme_font_preload_link_tags(theme) + + expect(html).to have_css('link[type="font/woff2"]', visible: false) + end + + it 'omits type for unknown format' do + theme = Pageflow::Theme.new(:test, + font_faces: [ + {family: 'Avenir', src: '/fonts/a.bin', preload: true} + ]) + + html = helper.scrolled_theme_font_preload_link_tags(theme) + + expect(html).to have_css('link[href="/fonts/a.bin"]:not([type])', visible: false) + end + + it 'skips faces that do not result in a font face rule' do + theme = Pageflow::Theme.new(:test, + font_faces: [ + {src: '/fonts/a.woff2', preload: true}, + {family: 'Oswald', preload: true}, + {family: 'Karla', src: '/fonts/a".woff2', preload: true} + ]) + + html = helper.scrolled_theme_font_preload_link_tags(theme) + + expect(html).to be_blank + end + + it 'resolves file role of uploaded theme customization file' do + entry = create(:published_entry, type_name: 'scrolled') + file = Pageflow.theme_customizations.upload_file( + site: entry.site, + entry_type_name: 'scrolled', + type_name: :font, + attachment: fixture_file_upload('font.woff2') + ) + Pageflow.theme_customizations.update( + site: entry.site, + entry_type_name: 'scrolled', + overrides: { + font_faces: [{family: 'font1', file_role: 'font_font1_400_normal', preload: true}] + }, + file_ids: {font_font1_400_normal: file.id} + ) + + html = helper.scrolled_theme_font_preload_link_tags(entry.theme) + + expect(html).to have_css('link[type="font/woff2"][href*="original/font.woff2"]', + visible: false) + end + + it 'handles missing theme option' do + theme = Pageflow::Theme.new(:test) + + html = helper.scrolled_theme_font_preload_link_tags(theme) + + expect(html).to be_blank + end + end + describe '#scrolled_theme_typography_rules' do it 'returns rules for theme' do theme = Pageflow::Theme.new(:test,