Skip to content

Commit c11c8a5

Browse files
authored
feat: ⚡ Download fonts on cache and put it on public folder (#10)
1 parent ab6c02f commit c11c8a5

File tree

9 files changed

+395
-86
lines changed

9 files changed

+395
-86
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
node_modules
1+
node_modules
2+
**/_fonts

lib/cache.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { createStorage } from 'unstorage';
2+
import fsDriver from 'unstorage/drivers/fs';
3+
4+
export const cacheBase = 'node_modules/.cache/fontless';
5+
6+
export const storage = createStorage({
7+
driver: fsDriver({ base: cacheBase }),
8+
});

lib/css/assets.ts

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import fsp from 'node:fs/promises';
2+
import { hash } from 'ohash';
3+
import { extname, join } from 'pathe';
4+
import { filename } from 'pathe/utils';
5+
import { hasProtocol, joinRelativeURL, joinURL } from 'ufo';
6+
import type { FontFaceData } from 'unifont';
7+
import { storage } from '../cache';
8+
import type { Options, RawFontFaceData } from '../types';
9+
import { formatToExtension, parseFont } from './render';
10+
11+
const renderedFontURLs = new Map<string, string>();
12+
13+
export async function setupPublicAssetStrategy(options: Options) {
14+
const { module } = options;
15+
16+
const assetsBaseURL = module.assets.prefix || '/fonts';
17+
18+
function normalizeFontData(
19+
faces: RawFontFaceData | FontFaceData[],
20+
): FontFaceData[] {
21+
const data: FontFaceData[] = [];
22+
for (const face of Array.isArray(faces) ? faces : [faces]) {
23+
data.push({
24+
...face,
25+
unicodeRange:
26+
face.unicodeRange === undefined || Array.isArray(face.unicodeRange)
27+
? face.unicodeRange
28+
: [face.unicodeRange],
29+
src: (Array.isArray(face.src) ? face.src : [face.src]).map((src) => {
30+
const source = typeof src === 'string' ? parseFont(src) : src;
31+
if (
32+
'url' in source &&
33+
hasProtocol(source.url, { acceptRelative: true })
34+
) {
35+
source.url = source.url.replace(/^\/\//, 'https://');
36+
const file = [
37+
// TODO: investigate why negative ignore pattern below is being ignored
38+
filename(source.url.replace(/\?.*/, '')).replace(/^-+/, ''),
39+
hash(source) +
40+
(extname(source.url) || formatToExtension(source.format) || ''),
41+
]
42+
.filter(Boolean)
43+
.join('-');
44+
45+
renderedFontURLs.set(file, source.url);
46+
source.originalURL = source.url;
47+
48+
source.url = options.fontless.dev
49+
? joinRelativeURL(assetsBaseURL, file)
50+
: joinURL(assetsBaseURL, file);
51+
}
52+
53+
return source;
54+
}),
55+
});
56+
}
57+
return data;
58+
}
59+
60+
const rollupBefore = async () => {
61+
for (const [filename, url] of renderedFontURLs) {
62+
const key = 'data:fonts:' + filename;
63+
// Use storage to cache the font data between builds
64+
let res = await storage.getItemRaw(key);
65+
if (!res) {
66+
res = await fetch(url)
67+
.then((r) => r.arrayBuffer())
68+
.then((r) => Buffer.from(r));
69+
70+
await storage.setItemRaw(key, res);
71+
}
72+
73+
// TODO: investigate how we can improve in dev surround
74+
await fsp.mkdir(join(options.fontless.baseURL, assetsBaseURL), {
75+
recursive: true,
76+
});
77+
78+
await fsp.writeFile(
79+
joinRelativeURL(options.fontless.baseURL, assetsBaseURL, filename),
80+
res,
81+
);
82+
}
83+
};
84+
85+
options.hooks['rollup:before'] = rollupBefore;
86+
87+
return {
88+
normalizeFontData,
89+
};
90+
}

lib/css/transformer.ts

+30-72
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,36 @@
1+
import type { CssNode, StyleSheet } from 'css-tree';
2+
import { parse, walk } from 'css-tree';
13
import { transform } from 'esbuild';
24
import { createJiti } from 'jiti';
35
import MagicString from 'magic-string';
4-
import { parse, walk } from 'css-tree';
5-
import { dirname, extname } from 'pathe';
6-
import { hasProtocol, withLeadingSlash } from 'ufo';
6+
import { dirname } from 'pathe';
7+
import { withLeadingSlash } from 'ufo';
78
import {
89
createUnifont,
910
type FontFaceData,
1011
type Provider,
1112
type RemoteFontSource,
1213
} from 'unifont';
14+
import type {
15+
FontFaceResolution,
16+
FontFamilyManualOverride,
17+
FontFamilyProviderOverride,
18+
ModuleOptions,
19+
Options,
20+
} from '../types';
21+
import { setupPublicAssetStrategy } from './assets';
1322
import {
14-
type GenericCSSFamily,
23+
addLocalFallbacks,
24+
extractEndOfFirstChild,
1525
extractFontFamilies,
1626
extractGeneric,
17-
extractEndOfFirstChild,
18-
addLocalFallbacks,
27+
type GenericCSSFamily,
1928
} from './parse';
2029
import {
21-
formatToExtension,
2230
generateFontFace,
2331
generateFontFallbacks,
24-
parseFont,
2532
relativiseFontSources,
2633
} from './render';
27-
import { hash } from 'ohash';
28-
import { filename } from 'pathe/utils';
29-
import type {
30-
ModuleOptions,
31-
RawFontFaceData,
32-
FontFamilyManualOverride,
33-
FontFamilyProviderOverride,
34-
Options,
35-
FontFaceResolution,
36-
} from '../types';
3734

3835
const defaultValues = {
3936
weights: [400],
@@ -76,6 +73,9 @@ async function defaultResolveFontFace(
7673
fallbackOptions,
7774
) {
7875
const { module } = options;
76+
77+
const { normalizeFontData } = await setupPublicAssetStrategy(options);
78+
7979
const override = module.families?.find((f) => f.name === fontFamily);
8080

8181
// This CSS will be injected in a separate location
@@ -90,46 +90,6 @@ async function defaultResolveFontFace(
9090
return addLocalFallbacks(fontFamily, font);
9191
}
9292

93-
function normalizeFontData(
94-
faces: RawFontFaceData | FontFaceData[],
95-
): FontFaceData[] {
96-
// const assetsBaseURL = module.assets.prefix || "/fonts"; //TODO: Review this if it's necessary?
97-
// const renderedFontURLs = new Map<string, string>(); //TODO: Review this if it's necessary?
98-
const data: FontFaceData[] = [];
99-
for (const face of Array.isArray(faces) ? faces : [faces]) {
100-
data.push({
101-
...face,
102-
unicodeRange:
103-
face.unicodeRange === undefined || Array.isArray(face.unicodeRange)
104-
? face.unicodeRange
105-
: [face.unicodeRange],
106-
src: (Array.isArray(face.src) ? face.src : [face.src]).map((src) => {
107-
const source = typeof src === 'string' ? parseFont(src) : src;
108-
if (
109-
'url' in source &&
110-
hasProtocol(source.url, { acceptRelative: true })
111-
) {
112-
source.url = source.url.replace(/^\/\//, 'https://');
113-
const file = [
114-
// TODO: investigate why negative ignore pattern below is being ignored
115-
filename(source.url.replace(/\?.*/, '')).replace(/^-+/, ''),
116-
hash(source) +
117-
(extname(source.url) || formatToExtension(source.format) || ''),
118-
]
119-
.filter(Boolean)
120-
.join('-');
121-
122-
// renderedFontURLs.set(file, source.url); //TODO: Review this if it's necessary?
123-
source.originalURL = source.url;
124-
// source.url = joinURL(assetsBaseURL, file); //TODO: Review this if it's necessary?
125-
}
126-
return source;
127-
}),
128-
});
129-
}
130-
return data;
131-
}
132-
13393
async function resolveFontFaceWithOverride(
13494
fontFamily: string,
13595
override?: FontFamilyManualOverride | FontFamilyProviderOverride,
@@ -155,19 +115,16 @@ async function defaultResolveFontFace(
155115
normalizedDefaults.fallbacks[fallbackOptions?.generic || 'sans-serif'];
156116

157117
if (override && 'src' in override) {
158-
const fonts = addFallbacks(
159-
fontFamily,
160-
normalizeFontData({
161-
src: override.src,
162-
display: override.display,
163-
weight: override.weight,
164-
style: override.style,
165-
}),
166-
);
118+
const fonts = normalizeFontData({
119+
src: override.src,
120+
display: override.display,
121+
weight: override.weight,
122+
style: override.style,
123+
});
167124

168125
return {
169126
fallbacks,
170-
fonts,
127+
fonts: addFallbacks(fontFamily, fonts),
171128
};
172129
}
173130

@@ -267,7 +224,7 @@ export async function transformCSS(
267224
opts: { relative?: boolean } = {},
268225
) {
269226
const { fontless } = options;
270-
const s = new MagicString(code);
227+
const string = new MagicString(code);
271228

272229
const injectedDeclarations = new Set<string>();
273230

@@ -356,11 +313,11 @@ export async function transformCSS(
356313
}
357314
}
358315

359-
s.prepend(prefaces.join(''));
316+
string.prepend(prefaces.join(''));
360317

361318
if (fallbackOptions && insertFontFamilies) {
362319
const insertedFamilies = fallbackMap.map((f) => `"${f.name}"`).join(', ');
363-
s.prependLeft(fallbackOptions.index, `, ${insertedFamilies}`);
320+
string.prependLeft(fallbackOptions.index, `, ${insertedFamilies}`);
364321
}
365322
}
366323

@@ -433,7 +390,7 @@ export async function transformCSS(
433390

434391
await Promise.all(promises);
435392

436-
return s;
393+
return string;
437394
}
438395

439396
async function resolveProviders(_providers: ModuleOptions['providers'] = {}) {
@@ -452,5 +409,6 @@ async function resolveProviders(_providers: ModuleOptions['providers'] = {}) {
452409
});
453410
}
454411
}
412+
455413
return providers as Record<string, (options: any) => Provider>;
456414
}

lib/index.ts

Whitespace-only changes.

lib/types.ts

+29-6
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,26 @@ import type {
99

1010
import type { GenericCSSFamily } from './css/parse';
1111

12+
export interface FontProvider {
13+
/**
14+
* Resolve data for `@font-face` declarations.
15+
*
16+
* If nothing is returned then this provider doesn't handle the font family and we
17+
* will continue calling `resolveFontFaces` in other providers.
18+
*/
19+
resolveFontFaces?: (
20+
fontFamily: string,
21+
options: ResolveFontOptions,
22+
) => Awaitable<{
23+
/**
24+
* Return data used to generate @font-face declarations.
25+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face
26+
*/
27+
fonts: FontFaceData[];
28+
fallbacks?: string[];
29+
}>;
30+
}
31+
1232
export type Awaitable<T> = T | Promise<T>;
1333

1434
export interface FontFaceData {
@@ -43,13 +63,22 @@ export interface FontFaceResolution {
4363
}
4464

4565
export interface FontlessOptions {
66+
baseURL: string;
4667
dev: boolean;
4768
processCSSVariables?: boolean;
4869
shouldPreload: (fontFamily: string, font: FontFaceData) => boolean;
4970
fontsToPreload: Map<string, Set<string>>;
5071
}
5172

73+
export interface ModuleHooks {
74+
'rollup:before'?: (options: Options) => Awaitable<void>;
75+
'fonts:providers'?: (
76+
providers: Record<string, ProviderFactory | FontProvider>,
77+
) => void | Promise<void>;
78+
}
79+
5280
export interface Options {
81+
hooks: ModuleHooks;
5382
module: ModuleOptions;
5483
fontless: FontlessOptions;
5584
}
@@ -206,9 +235,3 @@ export interface ModuleOptions {
206235
processCSSVariables?: boolean;
207236
};
208237
}
209-
210-
export interface ModuleHooks {
211-
'fonts:providers': (
212-
providers: Record<string, ProviderFactory | FontProvider>,
213-
) => void | Promise<void>;
214-
}

lib/vite/plugin.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
import type { transform, TransformOptions } from 'esbuild';
2+
import { providers } from 'unifont';
13
import type { ESBuildOptions, Plugin } from 'vite';
2-
import type { FontlessOptions, Options } from '../types';
3-
import type { transform } from 'esbuild';
4-
import type { TransformOptions } from 'esbuild';
54
import { transformCSS } from '../css/transformer';
6-
import { providers } from 'unifont';
75
import local from '../providers/local';
6+
import type { FontlessOptions, Options } from '../types';
87

98
const defaultModule = {
109
devtools: true,
@@ -34,13 +33,15 @@ const defaultModule = {
3433
};
3534

3635
const defaultFontless: FontlessOptions = {
37-
dev: false,
36+
baseURL: 'public',
37+
dev: process.env.NODE_ENV !== 'production',
3838
processCSSVariables: false,
3939
shouldPreload: () => false,
4040
fontsToPreload: new Map(),
4141
};
4242

43-
const defaultOptions = {
43+
const defaultOptions: Options = {
44+
hooks: {},
4445
module: defaultModule,
4546
fontless: defaultFontless,
4647
};
@@ -108,6 +109,9 @@ export const fontless = (options: Options = defaultOptions): Plugin => {
108109

109110
const s = await transformCSS(options, code, id, postcssOptions);
110111

112+
//TODO: Move this to a hook from vite
113+
options.hooks['rollup:before']?.(options);
114+
111115
if (s.hasChanged()) {
112116
return {
113117
code: s.toString(),

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"tinyglobby": "^0.2.10",
3131
"ufo": "^1.5.4",
3232
"unifont": "^0.1.6",
33-
"vite": "^6.0.3"
33+
"vite": "^6.0.3",
34+
"unstorage": "^1.14.1"
3435
}
3536
}

0 commit comments

Comments
 (0)