Skip to content

Commit 6f68341

Browse files
author
Adrian Bece
committed
Initial commit
0 parents  commit 6f68341

11 files changed

+351
-0
lines changed

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2020 Adrian Bece
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Gatsby Omni Font Loader
2+
3+
Performant asynchronous font loading plugin for Gatsby.
4+
5+
:globe_with_meridians: Supports web fonts
6+
<br/>
7+
:house: Suports self-hosted fonts
8+
<br/>
9+
:trident: Loads fonts asynchronously to avoid render blocking
10+
<br/>
11+
:stopwatch: Implemented with [fast loading snippets](https://csswizardry.com/2020/05/the-fastest-google-fonts/)
12+
<br/>
13+
:eyes: Loading status watcher for avoiding FOUT & FOUC
14+
15+
## Configuration
16+
17+
Add the following snippet to `gatsby-config.js` plugins array.
18+
19+
```js
20+
{
21+
/* Include plugin */
22+
resolve: "gatsby-omni-font-loader",
23+
24+
/* Plugin options */
25+
options: {
26+
27+
/* Enable font loading listener to handle FOUC */
28+
enableListener: true,
29+
30+
/* Preconnect URL-s. This example is for Google Fonts */
31+
preconnect: ["https://fonts.gstatic.com"],
32+
33+
/* Self-hosted fonts config. Add font files and font CSS files to "static" folder */
34+
custom: [
35+
{
36+
name: ["Font Awesome 5 Brands", "Font Awesome 5 Free"],
37+
file: "/fonts/fontAwesome/css/all.min.css",
38+
},
39+
],
40+
41+
/* Web fonts. File link should point to font CSS file. */
42+
web: [{
43+
name: "Staatliches",
44+
file: "https://fonts.googleapis.com/css2?family=Staatliches",
45+
},
46+
],
47+
},
48+
}
49+
```
50+
51+
## Handling FOUC with Font loading watcher
52+
53+
When loading fonts asynchronously, Flash Of Unstyled Content (FOUC) might happen because fonts load few moments later after page is displayed to the user.
54+
55+
To avoid this, we can use CSS to style the fallback font to closely match the font size, line height and letter spacing of the main font that is being loaded.
56+
57+
When `enableListener: true` is set in plugin config in `gatsby-config.js`, HTML classes are being added to `<body>` element as the fonts are being loaded.
58+
59+
HTML class name format will be in the following format `wf-[font-family-name]--loaded`.
60+
61+
You can use the [Font Style Matcher](https://meowni.ca/font-style-matcher/) to adjust the perfect fallback font and fallback CSS config.
62+
63+
Here is the example of how `body` element will look like after all fonts are being loaded (depending on the config).
64+
65+
```html
66+
<body class="wf-lazy-monday--loaded wf-font-awesome-5-brands--loaded wf-font-awesome-5-free--loaded wf-staatliches--loaded wf-henny-penny--loaded">
67+
```
68+
69+
<img alt="FOUC example" src="https://res.cloudinary.com/dazdt97d3/image/upload/v1604140006/fouc.gif">
70+
71+
72+
## Issues and Contributions
73+
74+
Feel free to report and issues in the "Issues tab" and feel free to contribute to the project by creating Pull Requests.
75+
76+
Contributions are welcome and appreciated!
77+
78+
## Support
79+
80+
The project is created and maintained by [Adrian Bece](https://codeadrian.github.io/) with the generous help of community contributors. If you have used the plugin and would like to contribute, feel free to [Buy Me A Coffee](https://www.buymeacoffee.com/ubnZ8GgDJ).
81+
82+
<a href="https://www.buymeacoffee.com/ubnZ8GgDJ" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-red.png" alt="Buy Me A Coffee" style="height: 51px !important;width: 217px !important;" ></a>

components/AsyncFonts.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React from "react"
2+
import { Helmet } from "react-helmet"
3+
4+
export const AsyncFonts: React.FC<{ hrefs: string[] }> = ({ hrefs }) => {
5+
const links = []
6+
7+
hrefs.forEach(href => {
8+
const noScript = (
9+
<noscript
10+
key={`noscript-${href}`}
11+
>{`<link rel="stylesheet" href="${href}" />`}</noscript>
12+
)
13+
const link = (
14+
<link
15+
key={`stylesheet-${href}`}
16+
rel="stylesheet"
17+
media="all"
18+
href={href}
19+
/>
20+
)
21+
22+
links.push([noScript, link])
23+
})
24+
25+
return <Helmet>{links}</Helmet>
26+
}

components/FontListener.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React, { useEffect, useState } from "react"
2+
import { Helmet } from "react-helmet"
3+
4+
declare var document: { fonts: any }
5+
6+
export const FontListener: React.FC<{ fontNames: string[] }> = ({
7+
fontNames,
8+
}) => {
9+
const [hasLoaded, setHasLoaded] = useState<Boolean>(false)
10+
const [loadedFonts, setLoadedFonts] = useState<string[]>([])
11+
const [intervalId, setIntervalId] = useState<number>(-1)
12+
13+
const apiAvailable = "fonts" in document
14+
15+
useEffect(() => {
16+
if (!apiAvailable) {
17+
setHasLoaded(true)
18+
setLoadedFonts(fontNames)
19+
return
20+
}
21+
22+
if (!hasLoaded && intervalId < 0) {
23+
const id = window.setInterval(isFontLoaded, 100)
24+
setIntervalId(id)
25+
}
26+
}, [hasLoaded, intervalId, apiAvailable])
27+
28+
useEffect(() => {
29+
if (hasLoaded && intervalId > 0) {
30+
clearInterval(intervalId)
31+
}
32+
}, [hasLoaded, intervalId])
33+
34+
const loadedClassname = Boolean(loadedFonts.length)
35+
? loadedFonts.map(fontName => `wf-${kebabCase(fontName)}--loaded`).join(" ")
36+
: ""
37+
38+
return (
39+
<Helmet>
40+
{Boolean(loadedFonts.length) && <body className={loadedClassname} />}
41+
</Helmet>
42+
)
43+
44+
function kebabCase(str) {
45+
return str
46+
.match(/[A-Z]{2,}(?=[A-Z][a-z0-9]*|\b)|[A-Z]?[a-z0-9]*|[A-Z]|[0-9]+/g)
47+
.filter(Boolean)
48+
.map(x => x.toLowerCase())
49+
.join("-")
50+
}
51+
52+
function isFontLoaded() {
53+
const loaded = []
54+
55+
const fontsLoading = fontNames.map(fontName => {
56+
let hasLoaded = false
57+
try {
58+
hasLoaded = document.fonts.check(`12px '${fontName}'`)
59+
} catch (error) {
60+
console.info(`document.fonts API error: ${error}`)
61+
console.info(`Replacing fonts instantly. FOUT handling failed.`)
62+
setHasLoaded(true)
63+
setLoadedFonts(fontNames)
64+
return
65+
}
66+
67+
if (hasLoaded) loaded.push(fontName)
68+
return hasLoaded
69+
})
70+
71+
const allFontsLoaded = fontsLoading.every(font => font)
72+
73+
if (loadedFonts.length !== loaded.length) {
74+
setLoadedFonts(loaded)
75+
}
76+
77+
if (allFontsLoaded) {
78+
setHasLoaded(true)
79+
}
80+
}
81+
}

components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./AsyncFonts"
2+
export * from "./FontListener"

gatsby-browser.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { AsyncFonts, FontListener } from "./components"
2+
import React from "react"
3+
4+
export const wrapRootElement = (
5+
{ element },
6+
{ custom, web, enableListener }
7+
) => {
8+
const allFonts = [...custom, ...web]
9+
const fontNames = []
10+
const fontFiles = allFonts.map(({ file }) => file)
11+
allFonts.forEach(({ name }) =>
12+
Array.isArray(name) ? fontNames.push(...name) : fontNames.push(name)
13+
)
14+
15+
const hasFontFiles = Boolean(fontFiles.length)
16+
const hasFontNames = Boolean(fontNames.length)
17+
18+
return (
19+
<>
20+
{hasFontNames && <AsyncFonts hrefs={fontFiles} />}
21+
{enableListener && hasFontFiles && <FontListener fontNames={fontNames} />}
22+
{element}
23+
</>
24+
)
25+
}

gatsby-ssr.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { getFontConfig, getTestFonts } from "./utils"
2+
3+
export const onRenderBody = (
4+
{ setHeadComponents, setPostBodyComponents },
5+
{ enableListener, preconnect, web, custom }
6+
) => {
7+
const allFonts = [...web, ...custom]
8+
const preload = allFonts.map(({ file }) => file)
9+
const fontNames = []
10+
allFonts.forEach(({ name }) =>
11+
Array.isArray(name) ? fontNames.push(...name) : fontNames.push(name)
12+
)
13+
14+
const hasPreconnect = Boolean(preconnect.length)
15+
const hasPreload = Boolean(preload.length)
16+
17+
if (!preconnect || !preload || !hasPreconnect || !hasPreload) return
18+
19+
const preloadConfig = getFontConfig(preconnect, preload)
20+
21+
if (enableListener) {
22+
const testFontConfig = getTestFonts(fontNames)
23+
setPostBodyComponents(testFontConfig)
24+
}
25+
26+
setHeadComponents(preloadConfig)
27+
}

package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "gatsby-omni-font-loader",
3+
"version": "1.0.0",
4+
"description": "Font loader optimized for maximum performance. Removes render-blocking font resources and loads them asynchronusly. Handle FOUT & FOUC with font loading status watcher. Supports both local-hosted fonts and web fonts.",
5+
"keywords": [
6+
"gatsby-plugin",
7+
"gatsby",
8+
"plugin",
9+
"async",
10+
"frontend",
11+
"web",
12+
"font",
13+
"loader",
14+
"google",
15+
"webfont",
16+
"local",
17+
"custom"
18+
],
19+
"author": "Adrian Bece <[email protected]>",
20+
"license": "MIT",
21+
"peerDependencies": {
22+
"gatsby": ">=1"
23+
}
24+
}

utils/getFontConfig.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from "react"
2+
3+
export const getFontConfig = (
4+
preconnectConfig: string[],
5+
preloadConfig: string[]
6+
) => {
7+
const headComponents = []
8+
9+
preconnectConfig.forEach(href => {
10+
headComponents.push(
11+
<link
12+
key={`preconnect-${href}`}
13+
rel="preconnect"
14+
href={href}
15+
crossOrigin="true"
16+
/>
17+
)
18+
})
19+
20+
preloadConfig.forEach(href => {
21+
headComponents.push(
22+
<link key={`preload-${href}`} rel="preload" as="style" href={href} />
23+
)
24+
})
25+
26+
return headComponents
27+
}

utils/getTestFonts.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from "react"
2+
3+
export const getTestFonts = (fontNames: string[]) => {
4+
const fontConfig = []
5+
6+
const hiddenStyles: React.CSSProperties = {
7+
position: "absolute",
8+
overflow: "hidden",
9+
clip: "rect(0 0 0 0)",
10+
height: "1px",
11+
width: "1px",
12+
margin: "-1px",
13+
padding: "0",
14+
border: "0",
15+
}
16+
17+
fontNames.forEach(fontName => {
18+
fontConfig.push(
19+
<span
20+
key={`wf-test-${fontName}`}
21+
aria-hidden="true"
22+
style={{ ...hiddenStyles, fontFamily: `"${fontName}"` }}
23+
>
24+
&nbsp;
25+
</span>
26+
)
27+
})
28+
29+
return (
30+
<span key="wf-test-wrapper" style={hiddenStyles}>
31+
{fontConfig}
32+
</span>
33+
)
34+
}

utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./getFontConfig"
2+
export * from "./getTestFonts"

0 commit comments

Comments
 (0)