Skip to content

Commit

Permalink
feat(webperf): replace google fonts by local
Browse files Browse the repository at this point in the history
  • Loading branch information
fpasquet committed Apr 15, 2024
1 parent a4ddc76 commit 088ac2c
Show file tree
Hide file tree
Showing 23 changed files with 320 additions and 11 deletions.
1 change: 0 additions & 1 deletion .gitignore
Expand Up @@ -24,7 +24,6 @@ reports
coverage
storybook-static
public/data
public/fonts
public/imgs
public/feed.xml
.env
20 changes: 20 additions & 0 deletions Dockerfile-fonttools
@@ -0,0 +1,20 @@
FROM node:18.16-alpine3.16

# Install Python
RUN apk add --no-cache python3

# Update package and install necessary build tools
RUN apk update && apk add --no-cache build-base python3 py3-pip

# Upgrade setuptools
RUN pip install -U setuptools

# Install brotli and fonttools
RUN pip install brotli fonttools

# Install glyphhanger
RUN npm install -g glyphhanger tsx

WORKDIR /var/www/app

USER node:node
40 changes: 40 additions & 0 deletions README.md
Expand Up @@ -288,3 +288,43 @@ git checkout -b feat/add-tutorial-slug
```

Once your tutorial is finished and you want it to be published and add the label `publication` to your pull request.

## Creating font subsets for web performance

To optimize font file sizes, it is recommended to create subsets of fonts. This process involves breaking down fonts into subsets, ensuring that web browsers only load the necessary parts of the font. The provided Docker commands utilize the fonttools tool to generate font subsets. The TypeScript files responsible for this process are located in the `src/themes/fonts` directory. Below are the Docker commands and their results for font subset creation:

```
# Build the fonttools Docker image
docker build -t fonttools -f Dockerfile-fonttools .
# Run the fonttools container to create font subsets
docker run -it -v ./:/var/www/app --rm fonttools tsx bin/optimize-fonts.ts
```

Result of the command:

```
Subsetting agdasima-bold.ttf to agdasima-bold-latin-ext.woff2 (was 23.012 kB, now 1.544 kB)
Subsetting agdasima-regular.ttf to agdasima-regular-latin-ext.woff2 (was 23.152 kB, now 1.56 kB)
Subsetting agdasima-regular.ttf to agdasima-regular-latin.woff2 (was 23.152 kB, now 10.416 kB)
Subsetting agdasima-bold.ttf to agdasima-bold-latin.woff2 (was 23.012 kB, now 10.304 kB)
Subsetting montserrat-medium.ttf to montserrat-medium-latin-ext.woff2 (was 197.756 kB, now 20.4 kB)
Subsetting montserrat-semi-bold.ttf to montserrat-semi-bold-latin-ext.woff2 (was 197.964 kB, now 20.512 kB)
Subsetting montserrat-regular.ttf to montserrat-regular-latin-ext.woff2 (was 197.624 kB, now 20.28 kB)
Subsetting montserrat-semi-bold.ttf to montserrat-semi-bold-latin.woff2 (was 197.964 kB, now 24.02 kB)
Subsetting montserrat-regular.ttf to montserrat-regular-latin.woff2 (was 197.624 kB, now 23.916 kB)
Subsetting montserrat-medium.ttf to montserrat-medium-latin.woff2 (was 197.756 kB, now 24.044 kB)
...
```

This output demonstrates the reduction in file size achieved by creating subsets for various fonts. The original font files are listed with their corresponding subset names and sizes before and after optimization.

Sources:

- [Article to creating font subsets](https://markoskon.com/creating-font-subsets/)
- [FontTools is a library for manipulating fonts, written in Python](https://fonttools.readthedocs.io/en/latest/subset/)
- [Characters table by language](https://character-table.netlify.app/)
- [To analyze a font (number of characters, glyphs, language support, layout features, etc.](https://wakamaifondue.com/)
- [Site listing all unicodes by range, alphabetically, type ...](https://symbl.cc/en/unicode/blocks/basic-latin/)
- [Wikipedia: List of Unicode characters](https://en.wikipedia.org/wiki/List_of_Unicode_characters)
- [Other site listing unicodes](https://www.unicode.org/charts/nameslist/index.html)
128 changes: 128 additions & 0 deletions bin/optimize-fonts.ts
@@ -0,0 +1,128 @@
#!/usr/bin/env node

import chalk from 'chalk';
import { exec } from 'node:child_process';
import { statSync } from 'node:fs';
import { resolve } from 'node:path';

import { fonts, subsets } from '../src/config/website/fonts';

// Define the directory for fonts
const fontsDir = resolve(process.cwd(), 'src/assets/fonts');
const fontsOutdir = resolve(process.cwd(), 'public/fonts');

const formatUnicode = (unicode: string): string => unicode.padStart(4, '0').toUpperCase();
const formatUnicodeFromNumber = (unicodeNumber: number, includePrefix: boolean = true): string => {
const formattedUnicode = formatUnicode(unicodeNumber.toString(16));

return includePrefix ? `U+${formattedUnicode}` : formattedUnicode;
};

const getUnicodeTableByUnicodeRange = (
unicodeRangeString: string
): { range: string; codePoints: number[]; unicodes: string[]; characters: string[] }[] =>
unicodeRangeString
.replace(/U\+/g, '')
.split(',')
.reduce<{ range: string; codePoints: number[]; unicodes: string[]; characters: string[] }[]>(
(unicodeTable, currentRange) => {
if (currentRange.includes('-')) {
const [start, end] = currentRange.split('-').map((i) => parseInt(i, 16));
const codePoints: number[] = Array.from({ length: end - start + 1 }, (_, index) => start + index);

return [
...unicodeTable,
{
range: currentRange,
codePoints,
unicodes: codePoints.map((codePoint) => formatUnicodeFromNumber(Number(codePoint))),
characters: codePoints.map((codePoint) => String.fromCharCode(codePoint)),
},
];
}

const codePoint: number = parseInt(currentRange, 16);

return [
...unicodeTable,
{
range: currentRange,
codePoints: [codePoint],
unicodes: [currentRange],
characters: [String.fromCharCode(codePoint)],
},
];
},
[]
);

// Define supported font formats
const formats: string[] = ['woff2'];

// Function to get file size in kilobytes
const getFileSizeInBytes = (filePath: string): string => {
const stats = statSync(filePath);

return `${stats.size / 1000} kB`;
};

const optimizeFonts = (): void => {
const unicodesInSubsets: string[] = [];
for (const [subset, unicodeRange] of Object.entries(subsets)) {
const unicodeTable = getUnicodeTableByUnicodeRange(unicodeRange);

const unicodes = unicodeTable.reduce<string[]>(
(currentUnicodes, item) => [...currentUnicodes, ...item.unicodes],
[]
);
unicodesInSubsets.push(...unicodes);
console.log(
`Here are the characters you selected in the \`${chalk.blue.bold(subset)}\` subset: ${unicodeTable
.map((item) => item.characters.map((character) => `\`${chalk.yellow(character)}\``))
.join(', ')}.\n`
);
}

// Loop through each font configuration
for (const { fontDirectoryName, styles } of fonts) {
// Define source and optimized directories
const sourcesDir = resolve(fontsDir, fontDirectoryName);

// Loop through each font style
for (const { fontFileName } of styles) {
const sourceFontFileNameWithExtension = `${fontFileName}.ttf`;
const sourceFontPath = resolve(sourcesDir, sourceFontFileNameWithExtension);
const sourceFontFileSize = getFileSizeInBytes(sourceFontPath);

for (const [subset, unicodeRange] of Object.entries(subsets)) {
for (const format of formats) {
const optimizedFontFileName = `${fontFileName}-${subset}.${format}`;
const optimizedFontPath = resolve(fontsOutdir, optimizedFontFileName);

const args = [
sourceFontPath,
`--output-file="${optimizedFontPath}"`,
`--flavor=${format}`,
'--layout-features="*"',
`--unicodes="${unicodeRange}"`,
];
exec(`pyftsubset ${args.join(' \\\n')}`, (error): void => {
if (error) {
console.error(`Error: ${error}`);

return;
}
const optimizedFontFileSize = getFileSizeInBytes(optimizedFontPath);
console.log(
`Subsetting ${chalk.bold(sourceFontFileNameWithExtension)} to ${chalk.bold(
optimizedFontFileName
)} (was ${chalk.red(sourceFontFileSize)}, now ${chalk.green(optimizedFontFileSize)})`
);
});
}
}
}
}
};

optimizeFonts();
Binary file added public/fonts/agdasima-bold-latin-ext.woff2
Binary file not shown.
Binary file added public/fonts/agdasima-bold-latin.woff2
Binary file not shown.
Binary file added public/fonts/agdasima-regular-latin-ext.woff2
Binary file not shown.
Binary file added public/fonts/agdasima-regular-latin.woff2
Binary file not shown.
Binary file added public/fonts/montserrat-medium-latin-ext.woff2
Binary file not shown.
Binary file added public/fonts/montserrat-medium-latin.woff2
Binary file not shown.
Binary file added public/fonts/montserrat-regular-latin-ext.woff2
Binary file not shown.
Binary file added public/fonts/montserrat-regular-latin.woff2
Binary file not shown.
Binary file not shown.
Binary file added public/fonts/montserrat-semi-bold-latin.woff2
Binary file not shown.
Binary file added src/assets/fonts/agdasima/agdasima-bold.ttf
Binary file not shown.
Binary file added src/assets/fonts/agdasima/agdasima-regular.ttf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
67 changes: 67 additions & 0 deletions src/config/website/fonts.ts
@@ -0,0 +1,67 @@
// Define the type for font weight
export type FontWeightType =
| 'thin'
| 'extra-light'
| 'light'
| 'regular'
| 'medium'
| 'semi-bold'
| 'bold'
| 'extra-bold'
| 'black';

export const fonts: {
fontFamilyName: string;
fontFamily: string;
fontDirectoryName: string;
styles: {
fontFileName: string;
fontWeight: FontWeightType;
isItalic?: boolean;
isPreload?: boolean;
}[];
}[] = [
{
fontFamilyName: 'Montserrat',
fontFamily: 'Montserrat, helvetica neue, helvetica, arial, sans-serif',
fontDirectoryName: 'montserrat',
styles: [
{
fontFileName: 'montserrat-regular',
fontWeight: 'regular',
},
{
fontFileName: 'montserrat-medium',
fontWeight: 'medium',
},
{
fontFileName: 'montserrat-semi-bold',
fontWeight: 'semi-bold',
},
],
},
{
fontFamilyName: 'Agdasima',
fontFamily: 'Agdasima',
fontDirectoryName: 'agdasima',
styles: [
{
fontFileName: 'agdasima-regular',
fontWeight: 'regular',
},
{
fontFileName: 'agdasima-bold',
fontWeight: 'bold',
},
],
},
];

// Define subsets for different languages
// Keep this order, because the one in first position will be used for the preload
export const subsets: Record<string, string> = {
latin:
'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
'latin-ext':
'U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF',
};
Expand Up @@ -71,10 +71,6 @@ export const useLayoutTemplateContainer = (): Omit<LayoutTemplateProps, 'childre
useLink({ rel: 'apple-touch-icon', sizes: '152x152', href: getPathFile('/imgs/icons/apple-icon-152x152.png') });
useLink({ rel: 'apple-touch-icon', sizes: '180x180', href: getPathFile('/imgs/icons/apple-icon-180x180.png') });

useLink({ rel: 'preconnect', href: 'https://fonts.googleapis.com' });
useLink({ rel: 'preconnect', href: 'https://fonts.gstatic.com' });
useLink({ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Work+Sans:wght@100..900&display=swap' });

return {
header: (
<>
Expand Down
8 changes: 2 additions & 6 deletions src/templates/HtmlTemplate/HtmlTemplate.tsx
Expand Up @@ -3,6 +3,7 @@ import * as React from 'react';

import { GTM_ID } from '@/constants';
import { getPathFile } from '@/helpers/assetHelper';
import { fontFaces } from '@/templates/HtmlTemplate/fontFaces';

export interface HtmlTemplateProps {
lang: string;
Expand Down Expand Up @@ -54,12 +55,7 @@ export const HtmlTemplate: React.FC<HtmlTemplateProps> = ({
))}
<link rel="shortcut icon" type="image/x-icon" href={getPathFile('/favicon.ico')} />
<link rel="manifest" href={getPathFile('/web-app-manifest.json')} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Agdasima:wght@400;700&family=Montserrat:wght@100;400;500;700&display=swap"
rel="stylesheet"
/>
<style dangerouslySetInnerHTML={{ __html: fontFaces }} />
{links?.map((link, index) => (
<link key={index} {...link} />
))}
Expand Down
63 changes: 63 additions & 0 deletions src/templates/HtmlTemplate/fontFaces.ts
@@ -0,0 +1,63 @@
import { fonts, FontWeightType, subsets } from '@/config/website/fonts';
import { getPathFile } from '@/helpers/assetHelper';

// Function to get numerical font weight based on the FontWeightType
const getFontWeightNumber = (fontWeight: FontWeightType): number => {
// eslint-disable-next-line default-case
switch (fontWeight) {
case 'thin':
return 100;
case 'extra-light':
return 200;
case 'light':
return 300;
case 'regular':
return 400;
case 'medium':
return 500;
case 'semi-bold':
return 600;
case 'bold':
return 700;
case 'extra-bold':
return 800;
case 'black':
return 900;
}
throw new Error(`This fontWeight "${fontWeight}" does not exist`);
};

const templateFontFace = (options: {
fontFamilyName: string;
fontPath: string;
fontWeight: FontWeightType;
unicodeRange: string;
isItalic?: boolean;
}): string =>
`@font-face {
font-family: '${options.fontFamilyName}';
font-style: ${options.isItalic ? 'italic' : 'normal'};
font-weight: ${getFontWeightNumber(options.fontWeight)};
font-display: swap;
src: url(${getPathFile(options.fontPath)}) format('woff2');
unicode-range: ${options.unicodeRange};
}`.replace(/\n|\s+(?!format)/g, '');

export const fontFaces = Object.entries(subsets)
.reduce<string[]>((currentFontFaces, [subsetName, unicodeRange]) => {
for (const font of fonts) {
for (const style of font.styles) {
currentFontFaces.push(
templateFontFace({
fontFamilyName: font.fontFamilyName,
fontPath: `/fonts/${style.fontFileName}-${subsetName}.woff2`,
fontWeight: style.fontWeight,
isItalic: style.isItalic,
unicodeRange,
})
);
}
}
return currentFontFaces;
}, [])
.join('');

0 comments on commit 088ac2c

Please sign in to comment.