Skip to content

Commit c7f1aa3

Browse files
committed
added custom local fonts support
1 parent 98ea7d7 commit c7f1aa3

File tree

3 files changed

+272
-9
lines changed

3 files changed

+272
-9
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,41 @@ export default {
100100
}
101101
]
102102
},
103+
104+
// Custom fonts.
105+
custom: {
106+
/**
107+
* Fonts families lists
108+
*/
109+
families: [{
110+
/**
111+
* Name of the font family.
112+
*/
113+
name: 'Roboto',
114+
/**
115+
* Local name of the font. Used to add `src: local()` to `@font-rule`.
116+
*/
117+
local: 'Roboto',
118+
/**
119+
* Regex(es) of font files to import. The names of the files will
120+
* predicate the `font-style` and `font-weight` values of the `@font-rule`'s.
121+
*/
122+
src: './src/assets/fonts/*.ttf',
123+
}],
124+
125+
/**
126+
* Defines the default `font-display` value used for the generated
127+
* `@font-rule` classes.
128+
*/
129+
display: 'auto'
130+
131+
/**
132+
* Using `<link rel="preload">` will trigger a request for the WebFont
133+
* early in the critical rendering path, without having to wait for the
134+
* CSSOM to be created.
135+
*/
136+
preload: true
137+
}
103138
})
104139
],
105140
}

src/custom.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { HtmlTagDescriptor, ResolvedConfig } from 'vite'
2+
import { sync as glob } from 'glob'
3+
import { chain as _ } from 'lodash'
4+
5+
type CustomFontFace = {
6+
src: string[]
7+
name?: string
8+
weight?: number | string
9+
style?: string
10+
display?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional'
11+
local?: string | string[]
12+
}
13+
14+
export type CustomFontFamily = {
15+
/**
16+
* Name of the font family.
17+
* @example 'Comic Sans MS'
18+
*/
19+
name: string
20+
/**
21+
* Regex(es) of font files to import. The names of the files will
22+
* predicate the `font-style` and `font-weight` values of the `@font-rule`'s.
23+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping
24+
*
25+
* @example
26+
* A value of `./RobotoBoldItalic.*` will create this `@font-rule`:
27+
*
28+
* ```css
29+
* font-face {
30+
* font-family: 'Roboto';
31+
* src: url(./RobotoBoldItalic.ttf) format('truetype')
32+
* url(./RobotoBoldItalic.woff) format('woff')
33+
* url(./RobotoBoldItalic.woff2) format('woff2');
34+
* font-weight: bold;
35+
* font-style: italic;
36+
* font-display: auto;
37+
* }
38+
* ```
39+
*/
40+
src: string | string[]
41+
/**
42+
* Local name of the font. Used to add `src: local()` to `@font-rule`.
43+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face#description
44+
*/
45+
local?: string | string[]
46+
}
47+
48+
export type CustomFonts = {
49+
/**
50+
* Font families.
51+
*/
52+
families: CustomFontFamily[] | Record<string, string | string[] | Omit<CustomFontFamily, 'name'>>
53+
/**
54+
* Defines the default `font-display` value used for the generated
55+
* `@font-rule` classes.
56+
* @see https://developer.mozilla.org/fr/docs/Web/CSS/@font-face/font-display
57+
* @default 'auto'
58+
*/
59+
display?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional'
60+
/**
61+
* Using `<link rel="preload">` will trigger a request for the WebFont
62+
* early in the critical rendering path, without having to wait for the
63+
* CSSOM to be created.
64+
* @see https://web.dev/optimize-webfont-loading/#preload-your-webfont-resources
65+
* @default true
66+
*/
67+
preload?: boolean
68+
}
69+
70+
const resolveWeight = (weightOrSrc?: string | number) => {
71+
if (typeof weightOrSrc === 'number') return weightOrSrc
72+
if (!weightOrSrc) return 400
73+
weightOrSrc = weightOrSrc.toLowerCase()
74+
if (weightOrSrc.includes('thin')) return 100
75+
if (weightOrSrc.includes('extralight')) return 200
76+
if (weightOrSrc.includes('ultralight')) return 200
77+
if (weightOrSrc.includes('light')) return 300
78+
if (weightOrSrc.includes('normal')) return 400
79+
if (weightOrSrc.includes('medium')) return 500
80+
if (weightOrSrc.includes('semibold')) return 600
81+
if (weightOrSrc.includes('demibold')) return 600
82+
if (weightOrSrc.includes('bold')) return 700
83+
if (weightOrSrc.includes('extrabold')) return 800
84+
if (weightOrSrc.includes('ultrabold')) return 800
85+
if (weightOrSrc.includes('black')) return 900
86+
if (weightOrSrc.includes('heavy')) return 900
87+
return 400
88+
}
89+
90+
const resolveStyle = (styleOrSrc?: string) => {
91+
if (!styleOrSrc) return 'normal'
92+
styleOrSrc = styleOrSrc.toLowerCase()
93+
if (styleOrSrc.includes('normal')) return 'normal'
94+
if (styleOrSrc.includes('italic')) return 'italic'
95+
if (styleOrSrc.includes('oblique')) return 'oblique'
96+
return 'normal'
97+
}
98+
99+
const createFontFaceCSS = (options: CustomFontFace) => {
100+
// --- Format sources.
101+
const srcs = _(options.src)
102+
.castArray()
103+
.filter(Boolean)
104+
.map((url) => {
105+
let format = url.split('.').pop()
106+
if (format === 'ttf') format = 'truetype'
107+
return `url('${url}') format('${format}')`
108+
})
109+
.join(',\n\t\t')
110+
.value()
111+
112+
// --- Format local.
113+
const local = _(options.local)
114+
.castArray()
115+
.filter(Boolean)
116+
.map(x => `local('${x}')`)
117+
.join(', ')
118+
.value()
119+
120+
// --- Merge local and sources.
121+
const src = [srcs, local].filter(Boolean).join(', ')
122+
123+
// --- Return CSS rule as string.
124+
return `@font-face {
125+
font-family: '${options.name}';
126+
src: ${src};
127+
font-weight: ${resolveWeight(options.weight || srcs)};
128+
font-style: ${resolveStyle(options.style ?? srcs)};
129+
font-display: ${options.display ?? 'auto'};
130+
}`
131+
}
132+
133+
const createFontFaceLink = (href: string) => {
134+
return {
135+
tag: 'link',
136+
attrs: {
137+
rel: 'preload',
138+
as: 'font',
139+
type: `font/${href.split('.').pop()}`,
140+
href,
141+
crossorigin: true,
142+
},
143+
}
144+
}
145+
146+
export default (options: CustomFonts, config: ResolvedConfig) => {
147+
const tags: HtmlTagDescriptor[] = []
148+
const css: string[] = []
149+
150+
// --- Extract and defaults plugin options.
151+
let {
152+
families = [],
153+
// eslint-disable-next-line prefer-const
154+
display = 'auto',
155+
// eslint-disable-next-line prefer-const
156+
preload = true,
157+
} = options
158+
159+
// --- Cast as array of `CustomFontFamily`.
160+
if (!Array.isArray(families)) {
161+
families = _(families)
162+
.map((family, name) => (Array.isArray(family) || typeof family === 'string')
163+
? { name, src: family }
164+
: { name, ...family },
165+
)
166+
.value()
167+
}
168+
169+
// --- Iterate over font families and their faces.
170+
for (const { name, src, local } of families) {
171+
// --- Resolve glob(s) and group faces with the same name.
172+
const faces = _(src)
173+
.castArray()
174+
.map(x => glob(x, { nodir: true, root: config.root, absolute: true }))
175+
.flatten()
176+
.groupBy(x => x.match(/(.*)\.(\w|\d)+$/)?.[1].toLowerCase())
177+
.filter(Boolean)
178+
.map(src => ({
179+
name,
180+
src,
181+
weight: resolveWeight(src[0]),
182+
style: resolveStyle(src[0]),
183+
display,
184+
local,
185+
}))
186+
.value()
187+
188+
const hrefs = _(faces)
189+
.flatMap(face => face.src)
190+
.map(src => src.replace(config.root, '.'))
191+
.value()
192+
193+
// --- Generate `<link>` tags.
194+
if (preload) tags.push(...hrefs.map(createFontFaceLink))
195+
196+
// --- Generate CSS `@font-face` rules.
197+
for (const face of faces) css.push(createFontFaceCSS(face))
198+
}
199+
200+
// --- Return tags and CSS.
201+
return {
202+
tags,
203+
css: css.join('\n\n'),
204+
}
205+
}

src/index.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,50 @@
11

2-
import type { Plugin, HtmlTagDescriptor } from 'vite'
2+
import type { Plugin, HtmlTagDescriptor, ResolvedConfig } from 'vite'
33
import injectGoogleFonts, { GoogleFonts } from './google-fonts'
44
import injectTypekitFonts, { TypeKitFonts } from './typekit'
5+
import injectCustomFonts, { CustomFonts } from './custom'
56

67
type VitePluginFontsOptions = {
78
google?: GoogleFonts
89
typekit?: TypeKitFonts
10+
custom?: CustomFonts
911
}
1012

11-
function VitePluginFonts(options: VitePluginFontsOptions = {}) {
13+
let config: ResolvedConfig
14+
const MODULE_ID = 'virtual:fonts.css'
15+
const MODULE_ID_RESOLVED = '/@vite-plugin-fonts/fonts.css'
16+
17+
function VitePluginFonts(options: VitePluginFontsOptions = {}): Plugin {
1218
return {
1319
name: 'vite-plugin-fonts',
20+
enforce: 'pre',
1421

15-
transformIndexHtml(): HtmlTagDescriptor[] {
16-
const tags: HtmlTagDescriptor[] = []
22+
configResolved(_config) {
23+
config = _config
24+
},
1725

18-
if (options.typekit)
19-
tags.push(...injectTypekitFonts(options.typekit))
26+
transformIndexHtml: {
27+
enforce: 'pre',
28+
transform: () => {
29+
const tags: HtmlTagDescriptor[] = []
30+
if (options.typekit)
31+
tags.push(...injectTypekitFonts(options.typekit))
32+
if (options.google)
33+
tags.push(...injectGoogleFonts(options.google))
34+
if (options.custom)
35+
tags.push(...injectCustomFonts(options.custom, config).tags)
36+
return tags
37+
},
38+
},
2039

21-
if (options.google)
22-
tags.push(...injectGoogleFonts(options.google))
40+
resolveId(id) {
41+
if (id === MODULE_ID)
42+
return MODULE_ID_RESOLVED
43+
},
2344

24-
return tags
45+
load(id) {
46+
if (id === MODULE_ID_RESOLVED)
47+
return options.custom ? injectCustomFonts(options.custom, config).css : ''
2548
},
2649
}
2750
}

0 commit comments

Comments
 (0)