Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion entry_types/scrolled/app/helpers/pageflow_scrolled/themes_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def scrolled_theme_properties_style_tag(theme)
].flatten

content_tag('style', raw(<<~CSS), data: {theme: ''})
#{scrolled_theme_font_face_rules(theme)}
:root {
#{declarations.join("\n")}
}
Expand All @@ -41,6 +42,12 @@ def scrolled_theme_properties_style_tag(theme)
CSS
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")
end

def scrolled_theme_typography_rules(theme)
RuleSet.new(prefix: 'typography').generate(theme.options.fetch(:typography, {}))
end
Expand All @@ -52,6 +59,123 @@ def scrolled_theme_properties_rules(theme)

private

# @api private
class FontFaceRule
FORMATS = {
'.woff2' => 'woff2',
'.woff' => 'woff',
'.ttf' => 'truetype',
'.otf' => 'opentype'
}.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/
UNICODE_RANGE_PATTERN = /\A\s*U\+[0-9a-f?]{1,6}(-[0-9a-f]{1,6})?
(\s*,\s*U\+[0-9a-f?]{1,6}(-[0-9a-f]{1,6})?)*\s*\z/xi

# Quotes and backslashes would allow breaking out of the quoted
# strings family names and urls are interpolated into.
UNSAFE_IN_FAMILY = /["'\\[[:cntrl:]]]/
UNSAFE_IN_URL = /["'\\\s]/

ABSOLUTE_URL = %r{\A((https?:)?//|/|data:)}

def initialize(face, theme:, &resolve_path)
@face = face
@theme = theme
@resolve_path = resolve_path
end

def generate
return if family.blank? || source_values.empty?

<<~CSS
@font-face {
#{declarations.join("\n ")}
}
CSS
end

private

attr_reader :face, :theme

def declarations
[
%(font-family: "#{family}";),
"src: #{source_values.join(', ')};",
'font-display: swap;',
*descriptors
]
end

def family
@family ||= face[:family].to_s.gsub(UNSAFE_IN_FAMILY, '').strip
end

def source_values
@source_values ||=
sources
.reject { |source| source[:url].match?(UNSAFE_IN_URL) }
.map { |source| source_value(source[:url], source[:format]) }
end

def sources
if face[:file_role]
sources_from_files
else
sources_from_src
end
end

def sources_from_files
Array(face[:file_role]).compact.filter_map do |role|
url = theme.files.dig(role.to_sym, :original)
{url: url.to_s, format: face[:format]} if url
end
end

def sources_from_src
Array(face[:src]).filter_map do |src|
src = {url: src} unless src.is_a?(Hash)
next if src[:url].blank?

{url: resolve_url(src[:url]), format: src.fetch(:format, face[:format])}
end
end

def resolve_url(url)
return url.to_s if url.to_s.match?(ABSOLUTE_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

return %(url("#{url}")) unless format

%(url("#{url}") format("#{format}"))
end

def extension(url)
File.extname(url.split(/[?#]/).first.to_s).downcase
end

def descriptors
[
['font-weight', face[:weight], WEIGHT_PATTERN],
['font-style', face[:style], STYLE_PATTERN],
['unicode-range', face[:unicode_range], UNICODE_RANGE_PATTERN]
].filter_map do |property, value, pattern|
"#{property}: #{value.to_s.strip};" if value.to_s.match?(pattern)
end
end
end

BREAKPOINTS = {
sm: '640px',
md: '768px',
Expand Down Expand Up @@ -128,7 +252,7 @@ def scrolled_theme_deep_declarations(hash, suffix = nil, prefix = [])

def extract_theme_directory_from_scrolled_theme_asset_path(theme, path)
if path.starts_with?('../shared/')
['shared', path.gsub!('../shared/', '')]
['shared', path.sub('../shared/', '')]
elsif path.starts_with?('../')
raise(ArgumentError,
'Upward navigation to other directory than the shared ' \
Expand Down
118 changes: 109 additions & 9 deletions entry_types/scrolled/doc/creating_themes/custom_typography.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,113 @@

## Custom Fonts

[Fontsource](https://github.com/fontsource/fontsource) is the
recommended way to load custom fonts. Add the npm package for the
font:
Place the font files inside the theme's asset directory and declare
one font face per weight/style combination:

``` ruby
entry_type_config.themes.register(:my_custom_theme,
# ...
font_faces: [
{family: 'Open Sans',
weight: '400',
style: 'normal',
src: 'fonts/open-sans-400-normal.woff2'},
{family: 'Open Sans',
weight: '700',
style: 'normal',
src: 'fonts/open-sans-700-normal.woff2'}
],
properties: {
root: {
entry_font_family: '"Open Sans", sans-serif',
widget_font_family: '"Open Sans", sans-serif'
}
})
```

Different fonts can be used for the main content of the entry and
widgets.

The resulting `@font-face` rules are rendered into the same style tag
as the theme's other custom properties - both in published entries and
in the editor. Since no separate stylesheet needs to be requested, the
browser can start loading font files earlier.

The following keys are supported:

| Key | Description |
| --- | ----------- |
| `family` | Font family name to reference in font family properties and typography rules. |
| `src` | Path or url of the font file. See below. |
| `format` | Format of the font file. Derived from the file extension by default. |
| `weight` | Weight provided by the font file. Either a single value or a range like `'300 900'` for variable fonts. |
| `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`. |

`font-display: swap` is always included so that text remains visible
while font files are loading. Faces with invalid values are skipped.

### Font File Paths

Relative `src` paths are resolved inside the theme's asset directory,
just like icons and logos. Paths starting with `../shared/` refer to
the shared theme directory:

``` ruby
src: '../shared/fonts/open-sans-400-normal.woff2'
```

Absolute urls and paths are used as is.

### Multiple Formats

Pass an array to let the browser pick the first format it supports:

``` ruby
{family: 'Open Sans',
weight: '400',
src: ['fonts/open-sans-400-normal.woff2',
'fonts/open-sans-400-normal.woff']}
```

The `format` key applies to all sources of the face. Use hashes to
specify formats per source:

``` ruby
{family: 'Open Sans Variable',
weight: '300 900',
src: [{url: 'fonts/open-sans-wght-normal.woff2', format: 'woff2-variations'},
'fonts/open-sans-400-normal.woff']}
```

### Reducing Font File Size

Fonts that support many scripts can be split into subsets. Declare one
face per subset and use `unicode_range` to let the browser download
only those subsets that contain code points used in the entry:

``` ruby
font_faces: [
{family: 'Open Sans',
weight: '400',
src: 'fonts/open-sans-latin-400-normal.woff2',
unicode_range: 'U+0000-00FF, U+0131, U+0152-0153'},
{family: 'Open Sans',
weight: '400',
src: 'fonts/open-sans-latin-ext-400-normal.woff2',
unicode_range: 'U+0100-024F, U+0259, U+1E00-1EFF'}
]
```

Packages published by
[Fontsource](https://github.com/fontsource/fontsource) contain the
ranges of their subsets in a `unicode.json` file.

### Loading Fonts via Stylesheet Packs

Alternatively, fonts can be loaded by referencing a stylesheet pack
which contains `@font-face` rules. Add the npm package for the font:

$ yarn add @fontsource/open-sans

Expand All @@ -16,13 +120,12 @@ Create a Webpacker entry point file for your font:
@import "@fontsource/open-sans/700.css";
```

Adjust theme options to load the font stylesheet pack and set the font
family properties:
Adjust theme options to load the font stylesheet pack:

``` ruby
entry_type_config.themes.register(:my_custom_theme,
# ...
stylesheet_packs: ['font/openSans'],
stylesheet_packs: ['fonts/openSans'],
properties: {
root: {
entry_font_family: '"Open Sans", sans-serif',
Expand All @@ -31,9 +134,6 @@ entry_type_config.themes.register(:my_custom_theme,
})
```

Different fonts can be used for the main content of the entry and
widgets.

## Typography Rules

Aspects like font size, font weight, letter spacing, margins etc. can
Expand Down
6 changes: 6 additions & 0 deletions entry_types/scrolled/lib/pageflow_scrolled.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def entry_type
theme_files: {
logo_mobile: LOGO_OPTIONS,
logo_desktop: LOGO_OPTIONS,
font: FONT_OPTIONS,
**FAVICONS
})
end
Expand All @@ -40,6 +41,11 @@ def editor_fragment_renderer
end
}.freeze

FONT_OPTIONS = {
content_type: %r{\A(font/woff2?|application/(x-)?font-woff2?)\z},
styles: {original: {}}
}.freeze

FAVICONS = {
favicon: {
content_type: %r{\Aimage/svg\+xml\z},
Expand Down
Binary file added entry_types/scrolled/spec/fixtures/font.woff
Binary file not shown.
Binary file added entry_types/scrolled/spec/fixtures/font.woff2
Binary file not shown.
Loading
Loading