vite-plugin-local-webfonts 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -0
- package/dist/index.d.ts +230 -0
- package/dist/index.js +764 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Vite font plugin
|
|
2
|
+
|
|
3
|
+
Download web fonts from [Google Fonts](https://fonts.google.com), [Bunny Fonts](https://fonts.bunny.net), [Fontshare](https://www.fontshare.com), [Adobe Fonts](https://fonts.adobe.com), and other sources into your Vite build output — no external CSS requests at runtime, full privacy, and easy to extend with additional font sources.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Downloads font files at build time into `dist/fonts/` (configurable)
|
|
8
|
+
- Generates a `fonts.css` stylesheet with `@font-face` rules, CSS variables, and utility classes
|
|
9
|
+
- Injects the stylesheet and `<link rel="preload">` tags into your `index.html`
|
|
10
|
+
- Serves the fonts from the dev server during development
|
|
11
|
+
- Caches downloads in `node_modules/.cache/vite-plugin-fonts` for fast rebuilds
|
|
12
|
+
- Built-in providers for Google Fonts, Bunny Fonts, Fontshare, and Adobe Fonts
|
|
13
|
+
- Extensible: add any other font source with a few lines of code
|
|
14
|
+
- Zero runtime dependencies
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm install -D vite-plugin-local-webfonts
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// vite.config.ts
|
|
26
|
+
import { defineConfig } from 'vite'
|
|
27
|
+
import fonts, { adobe, bunny, fontshare, google } from 'vite-plugin-local-webfonts'
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
plugins: [
|
|
31
|
+
fonts({
|
|
32
|
+
fonts: [
|
|
33
|
+
google('Inter', {
|
|
34
|
+
weights: [400, 700],
|
|
35
|
+
styles: ['normal', 'italic'],
|
|
36
|
+
fallbacks: ['ui-sans-serif', 'system-ui', 'sans-serif'],
|
|
37
|
+
}),
|
|
38
|
+
bunny('Roboto', { weights: [400, 700] }),
|
|
39
|
+
fontshare('Satoshi', { weights: [400, 700] }),
|
|
40
|
+
adobe('proxima-nova', 'https://use.typekit.net/abcdefg.css'),
|
|
41
|
+
],
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
On `vite build` this emits:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
dist/
|
|
51
|
+
fonts/
|
|
52
|
+
inter-variable-normal-latin.woff2
|
|
53
|
+
inter-variable-italic-latin.woff2
|
|
54
|
+
roboto-400-normal-latin.woff2
|
|
55
|
+
fonts.css
|
|
56
|
+
index.html (with <link rel="stylesheet" href="/fonts/fonts.css"> and preload tags injected)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The generated `fonts.css` contains the `@font-face` rules plus a CSS variable and a
|
|
60
|
+
utility class per family:
|
|
61
|
+
|
|
62
|
+
```css
|
|
63
|
+
:root {
|
|
64
|
+
--font-inter: "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.font-inter {
|
|
68
|
+
font-family: var(--font-inter);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
During `vite dev`, the fonts are served from the dev server and injected into the page automatically.
|
|
73
|
+
|
|
74
|
+
## Font options
|
|
75
|
+
|
|
76
|
+
| Option | Default | Description |
|
|
77
|
+
| ----------- | ------------------ | --------------------------------------------------------------------------- |
|
|
78
|
+
| `weights` | `[400]` | Weights to download, e.g. `[400, 700]`. |
|
|
79
|
+
| `styles` | `['normal']` | `'normal'` and/or `'italic'`. |
|
|
80
|
+
| `subsets` | `['latin']` | Subsets to keep, e.g. `['latin', 'latin-ext']`. |
|
|
81
|
+
| `display` | `'swap'` | The `font-display` value. |
|
|
82
|
+
| `preload` | `true` | Inject `<link rel="preload">` tags for the WOFF2 files. |
|
|
83
|
+
| `fallbacks` | `[]` | Fallback families appended in the CSS variable, e.g. `['system-ui', 'sans-serif']`. |
|
|
84
|
+
| `alias` | slug of the family | Used for the utility class (`.font-{alias}`). |
|
|
85
|
+
| `variable` | `--font-{alias}` | The CSS variable holding the font stack. Must start with `--`. |
|
|
86
|
+
|
|
87
|
+
## Plugin options
|
|
88
|
+
|
|
89
|
+
| Option | Default | Description |
|
|
90
|
+
| ------------- | ------------------------------------------ | ---------------------------------------------------- |
|
|
91
|
+
| `fonts` | — | Font definitions (required). |
|
|
92
|
+
| `outputDir` | `'fonts'` | Directory inside the build output for fonts and CSS. |
|
|
93
|
+
| `cssFileName` | `'fonts.css'` | Name of the generated stylesheet. |
|
|
94
|
+
| `cacheDir` | `'node_modules/.cache/vite-plugin-fonts'` | Where downloads are cached. |
|
|
95
|
+
| `inject` | `true` | Inject stylesheet/preload tags into `index.html`. |
|
|
96
|
+
| `dev` | `true` | Serve and inject fonts during development. |
|
|
97
|
+
|
|
98
|
+
## Built-in providers
|
|
99
|
+
|
|
100
|
+
| Provider | Helper | Notes |
|
|
101
|
+
| ----------- | ----------------------------------------- | ------------------------------------------------------------ |
|
|
102
|
+
| Google Fonts | `google(family, options?)` | Uses the CSS2 API. |
|
|
103
|
+
| Bunny Fonts | `bunny(family, options?)` | GDPR-friendly drop-in replacement for Google Fonts. |
|
|
104
|
+
| Fontshare | `fontshare(family, options?)` | Downloads WOFF2 only. |
|
|
105
|
+
| Adobe Fonts | `adobe(family, kitUrl, options?)` | Requires your kit URL, e.g. `https://use.typekit.net/xyz.css`. |
|
|
106
|
+
|
|
107
|
+
Each provider also exports its underlying `FontProvider` (`googleProvider`,
|
|
108
|
+
`bunnyProvider`, `fontshareProvider`, `adobeProvider(kitUrl)`) in case you want
|
|
109
|
+
to reuse it with `defineFont` directly.
|
|
110
|
+
|
|
111
|
+
## Adding font sources
|
|
112
|
+
|
|
113
|
+
Any source that serves CSS with `@font-face` rules can be added with
|
|
114
|
+
`createCssApiProvider` — no changes to the plugin core required:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import fonts, { createCssApiProvider, defineFont } from 'vite-plugin-local-webfonts'
|
|
118
|
+
|
|
119
|
+
const myFonts = createCssApiProvider({
|
|
120
|
+
name: 'my-fonts',
|
|
121
|
+
baseUrl: 'https://fonts.example.com/css2', // any Google-compatible CSS2 API
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
export default defineConfig({
|
|
125
|
+
plugins: [
|
|
126
|
+
fonts({
|
|
127
|
+
fonts: [
|
|
128
|
+
defineFont('My Font', myFonts, { weights: [400, 700] }),
|
|
129
|
+
],
|
|
130
|
+
}),
|
|
131
|
+
],
|
|
132
|
+
})
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### `createCssApiProvider` options
|
|
136
|
+
|
|
137
|
+
| Option | Default | Description |
|
|
138
|
+
| ---------------- | ----------------------------- | ------------------------------------------------------------------------ |
|
|
139
|
+
| `name` | — | Provider name, used in error messages. |
|
|
140
|
+
| `baseUrl` | — | Base URL of the CSS API. |
|
|
141
|
+
| `buildUrl` | Google CSS2 URL builder | Build the request URL for a font definition. |
|
|
142
|
+
| `headers` | WOFF2-capable user agent | Headers for CSS and font file requests (object or function). |
|
|
143
|
+
| `transformFaces` | — | Post-process parsed `@font-face` rules before filtering and downloading. |
|
|
144
|
+
| `filterSubsets` | `true` | Filter rules by requested subsets using the CSS comment labels. |
|
|
145
|
+
| `formats` | all | Only download these formats, e.g. `['woff2']`. |
|
|
146
|
+
|
|
147
|
+
### Fully custom providers
|
|
148
|
+
|
|
149
|
+
For sources without a CSS API, implement the `FontProvider` interface directly:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import type { FontProvider } from 'vite-plugin-local-webfonts'
|
|
153
|
+
|
|
154
|
+
const myCdnProvider: FontProvider = {
|
|
155
|
+
name: 'my-cdn',
|
|
156
|
+
async resolve(definition, context) {
|
|
157
|
+
const variants = []
|
|
158
|
+
|
|
159
|
+
for (const weight of definition.weights) {
|
|
160
|
+
const url = `https://cdn.example.com/${definition.family}-${weight}.woff2`
|
|
161
|
+
|
|
162
|
+
variants.push({
|
|
163
|
+
weight,
|
|
164
|
+
style: 'normal',
|
|
165
|
+
files: [{
|
|
166
|
+
source: await context.fetchFile(url), // downloaded & cached
|
|
167
|
+
format: 'woff2',
|
|
168
|
+
}],
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return variants
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
defineFont('My Font', myCdnProvider, { weights: [400, 700] })
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `context` provides `fetchText(url)` (cached text download), `fetchFile(url)`
|
|
180
|
+
(cached binary download, resolves to the local file path), `parseFontFaces(css)`
|
|
181
|
+
(the built-in `@font-face` parser), and `warn(message)`.
|
|
182
|
+
|
|
183
|
+
## How it works
|
|
184
|
+
|
|
185
|
+
1. For each configured family, the provider's CSS API is fetched (with a
|
|
186
|
+
WOFF2-capable user agent for Google/Bunny) and the `@font-face` rules are parsed.
|
|
187
|
+
2. Rules are filtered to the requested family, weights, styles, and subsets.
|
|
188
|
+
3. Referenced font files are downloaded into the cache directory.
|
|
189
|
+
4. On build, files are emitted to `dist/<outputDir>/` with stable, readable names
|
|
190
|
+
(`inter-variable-normal-latin.woff2`) and a `fonts.css` stylesheet is generated
|
|
191
|
+
with relative URLs, so it works with any `base` configuration.
|
|
192
|
+
5. The stylesheet link and preload tags are injected into `index.html`.
|
|
193
|
+
6. In dev, the same flow serves files from the dev server under `/__fonts/`.
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
type FontFormat = 'woff2' | 'woff' | 'ttf' | 'otf' | 'eot';
|
|
4
|
+
type FontStyle = 'normal' | 'italic';
|
|
5
|
+
type FontWeight = number | string;
|
|
6
|
+
type FontDisplay = 'auto' | 'block' | 'swap' | 'fallback' | 'optional';
|
|
7
|
+
type FontOptions = {
|
|
8
|
+
/** @default [400] */
|
|
9
|
+
weights?: FontWeight[];
|
|
10
|
+
/** @default ['normal'] */
|
|
11
|
+
styles?: FontStyle[];
|
|
12
|
+
/** @default ['latin'] */
|
|
13
|
+
subsets?: string[];
|
|
14
|
+
/** @default 'swap' */
|
|
15
|
+
display?: FontDisplay;
|
|
16
|
+
/**
|
|
17
|
+
* Inject `<link rel="preload">` tags for the downloaded WOFF2 files.
|
|
18
|
+
*
|
|
19
|
+
* @default true
|
|
20
|
+
*/
|
|
21
|
+
preload?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Fallback font families appended after the downloaded family in the
|
|
24
|
+
* generated CSS variable, e.g. `['ui-sans-serif', 'system-ui', 'sans-serif']`.
|
|
25
|
+
*
|
|
26
|
+
* @default []
|
|
27
|
+
*/
|
|
28
|
+
fallbacks?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Used to reference the font in the generated utility class
|
|
31
|
+
* (`.font-{alias}`). Defaults to a slug of the family name.
|
|
32
|
+
*/
|
|
33
|
+
alias?: string;
|
|
34
|
+
/**
|
|
35
|
+
* CSS variable holding the font stack. Defaults to `--font-{alias}`.
|
|
36
|
+
*/
|
|
37
|
+
variable?: string;
|
|
38
|
+
};
|
|
39
|
+
type FontDefinition = Required<FontOptions> & {
|
|
40
|
+
family: string;
|
|
41
|
+
provider: FontProvider;
|
|
42
|
+
};
|
|
43
|
+
type ResolvedFontFile = {
|
|
44
|
+
/** Absolute path of the downloaded file on disk. */
|
|
45
|
+
source: string;
|
|
46
|
+
format: FontFormat;
|
|
47
|
+
unicodeRange?: string;
|
|
48
|
+
/** Subset label (e.g. "latin"), used to build readable file names. */
|
|
49
|
+
subset?: string;
|
|
50
|
+
};
|
|
51
|
+
type ResolvedFontVariant = {
|
|
52
|
+
weight: FontWeight;
|
|
53
|
+
style: FontStyle;
|
|
54
|
+
files: ResolvedFontFile[];
|
|
55
|
+
};
|
|
56
|
+
type ResolvedFontFamily = {
|
|
57
|
+
definition: FontDefinition;
|
|
58
|
+
variants: ResolvedFontVariant[];
|
|
59
|
+
};
|
|
60
|
+
type ParsedFontFace = {
|
|
61
|
+
family: string;
|
|
62
|
+
style: FontStyle;
|
|
63
|
+
weight: FontWeight;
|
|
64
|
+
src: ParsedFontSrc[];
|
|
65
|
+
unicodeRange?: string;
|
|
66
|
+
display?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The subset label from the CSS comment preceding the rule (e.g. "latin"),
|
|
69
|
+
* as emitted by the Google and Bunny CSS APIs.
|
|
70
|
+
*/
|
|
71
|
+
subset?: string;
|
|
72
|
+
};
|
|
73
|
+
type ParsedFontSrc = {
|
|
74
|
+
url: string;
|
|
75
|
+
format: FontFormat;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Context handed to providers when resolving a font family. Providers use it
|
|
79
|
+
* to download CSS and font files (with caching) and to parse `@font-face` CSS.
|
|
80
|
+
*/
|
|
81
|
+
type FontProviderContext = {
|
|
82
|
+
/** Fetch a text resource, cached on disk. */
|
|
83
|
+
fetchText: (url: string, init?: RequestInit) => Promise<string>;
|
|
84
|
+
/** Fetch a binary file, cached on disk. Resolves to the cached file path. */
|
|
85
|
+
fetchFile: (url: string, init?: RequestInit) => Promise<string>;
|
|
86
|
+
/** Parse `@font-face` rules from CSS. */
|
|
87
|
+
parseFontFaces: (css: string) => ParsedFontFace[];
|
|
88
|
+
warn: (message: string) => void;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* A font source. Implement this interface to add support for additional
|
|
92
|
+
* sources (Adobe Fonts, Fontshare, self-hosted CDNs, ...).
|
|
93
|
+
*
|
|
94
|
+
* For sources exposing a CSS API that returns `@font-face` rules, use the
|
|
95
|
+
* `createCssApiProvider` helper instead of implementing this by hand.
|
|
96
|
+
*/
|
|
97
|
+
interface FontProvider {
|
|
98
|
+
name: string;
|
|
99
|
+
resolve: (definition: FontDefinition, context: FontProviderContext) => Promise<ResolvedFontVariant[]>;
|
|
100
|
+
}
|
|
101
|
+
type FontsPluginOptions = {
|
|
102
|
+
/** Font families to download. */
|
|
103
|
+
fonts: FontDefinition[];
|
|
104
|
+
/**
|
|
105
|
+
* Directory (relative to the build output) for the font files and the
|
|
106
|
+
* generated stylesheet.
|
|
107
|
+
*
|
|
108
|
+
* @default 'fonts'
|
|
109
|
+
*/
|
|
110
|
+
outputDir?: string;
|
|
111
|
+
/**
|
|
112
|
+
* Name of the generated stylesheet, inside `outputDir`.
|
|
113
|
+
*
|
|
114
|
+
* @default 'fonts.css'
|
|
115
|
+
*/
|
|
116
|
+
cssFileName?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Cache directory for downloaded CSS and font files.
|
|
119
|
+
*
|
|
120
|
+
* @default 'node_modules/.cache/vite-plugin-fonts'
|
|
121
|
+
*/
|
|
122
|
+
cacheDir?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Inject the stylesheet and preload links into `index.html`.
|
|
125
|
+
*
|
|
126
|
+
* @default true
|
|
127
|
+
*/
|
|
128
|
+
inject?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Serve the fonts from the dev server and inject them into `index.html`
|
|
131
|
+
* during development.
|
|
132
|
+
*
|
|
133
|
+
* @default true
|
|
134
|
+
*/
|
|
135
|
+
dev?: boolean;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
declare function fonts(options: FontsPluginOptions): Plugin;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a font definition for any provider. Use this together with
|
|
142
|
+
* `createCssApiProvider` (or a hand-written provider) to support font
|
|
143
|
+
* sources beyond the built-in Google and Bunny providers.
|
|
144
|
+
*/
|
|
145
|
+
declare function defineFont(family: string, provider: FontProvider, options?: FontOptions): FontDefinition;
|
|
146
|
+
|
|
147
|
+
declare function parseFontFaceCss(css: string): ParsedFontFace[];
|
|
148
|
+
|
|
149
|
+
type CssApiProviderOptions = {
|
|
150
|
+
/** Provider name, used in error messages. */
|
|
151
|
+
name: string;
|
|
152
|
+
/**
|
|
153
|
+
* Base URL of the CSS API, e.g. `https://fonts.googleapis.com/css2`.
|
|
154
|
+
*/
|
|
155
|
+
baseUrl: string;
|
|
156
|
+
/**
|
|
157
|
+
* Build the CSS request URL for a font family. Defaults to the
|
|
158
|
+
* Google Fonts CSS2 API format (`?family=X:ital,wght@...&display=...`),
|
|
159
|
+
* which Bunny Fonts also supports.
|
|
160
|
+
*/
|
|
161
|
+
buildUrl?: (definition: FontDefinition, baseUrl: string) => string;
|
|
162
|
+
/**
|
|
163
|
+
* Headers sent with the CSS and font file requests. Defaults to a
|
|
164
|
+
* WOFF2-capable browser user agent, which the Google and Bunny CSS
|
|
165
|
+
* APIs require to serve WOFF2.
|
|
166
|
+
*/
|
|
167
|
+
headers?: Record<string, string> | ((definition: FontDefinition) => Record<string, string>);
|
|
168
|
+
/**
|
|
169
|
+
* Post-process the parsed `@font-face` rules before filtering and
|
|
170
|
+
* downloading. Useful for kit-based APIs (e.g. Adobe Fonts) that
|
|
171
|
+
* return every family and weight in the kit.
|
|
172
|
+
*/
|
|
173
|
+
transformFaces?: (faces: ParsedFontFace[], definition: FontDefinition) => ParsedFontFace[];
|
|
174
|
+
/**
|
|
175
|
+
* Filter the parsed rules by the requested subsets, using the subset
|
|
176
|
+
* labels in the CSS comments (e.g. `\/\* latin \*\/`). Disable for APIs
|
|
177
|
+
* whose comments are not subset labels (e.g. Fontshare labels rules with
|
|
178
|
+
* the family name).
|
|
179
|
+
*
|
|
180
|
+
* @default true
|
|
181
|
+
*/
|
|
182
|
+
filterSubsets?: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Only download these formats, e.g. `['woff2']`. By default every
|
|
185
|
+
* format referenced in the CSS is downloaded.
|
|
186
|
+
*/
|
|
187
|
+
formats?: FontFormat[];
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Build the request URL for the Google Fonts CSS2 API
|
|
191
|
+
* (also supported by Bunny Fonts).
|
|
192
|
+
*/
|
|
193
|
+
declare function buildCss2Url(definition: FontDefinition, baseUrl: string): string;
|
|
194
|
+
/**
|
|
195
|
+
* Create a font provider for any source that exposes a CSS API returning
|
|
196
|
+
* `@font-face` rules (Google Fonts, Bunny Fonts, Adobe Fonts, Fontshare, ...).
|
|
197
|
+
*/
|
|
198
|
+
declare function createCssApiProvider(options: CssApiProviderOptions): FontProvider;
|
|
199
|
+
|
|
200
|
+
declare const googleProvider: FontProvider;
|
|
201
|
+
declare const bunnyProvider: FontProvider;
|
|
202
|
+
declare const fontshareProvider: FontProvider;
|
|
203
|
+
/**
|
|
204
|
+
* Create a provider for an Adobe Fonts kit (https://fonts.adobe.com).
|
|
205
|
+
*
|
|
206
|
+
* The kit URL serves one CSS file containing every family in the kit; the
|
|
207
|
+
* plugin automatically keeps only the requested family, weights, and styles.
|
|
208
|
+
*/
|
|
209
|
+
declare function adobeProvider(kitUrl: string): FontProvider;
|
|
210
|
+
/**
|
|
211
|
+
* Download a family from Google Fonts (https://fonts.google.com).
|
|
212
|
+
*/
|
|
213
|
+
declare function google(family: string, options?: FontOptions): FontDefinition;
|
|
214
|
+
/**
|
|
215
|
+
* Download a family from Bunny Fonts (https://fonts.bunny.net), a GDPR-friendly
|
|
216
|
+
* drop-in replacement for Google Fonts.
|
|
217
|
+
*/
|
|
218
|
+
declare function bunny(family: string, options?: FontOptions): FontDefinition;
|
|
219
|
+
/**
|
|
220
|
+
* Download a family from Fontshare (https://www.fontshare.com).
|
|
221
|
+
*/
|
|
222
|
+
declare function fontshare(family: string, options?: FontOptions): FontDefinition;
|
|
223
|
+
/**
|
|
224
|
+
* Download a family from an Adobe Fonts kit (https://fonts.adobe.com).
|
|
225
|
+
*
|
|
226
|
+
* Pass your kit URL, e.g. `adobe('proxima-nova', 'https://use.typekit.net/abcdefg.css')`.
|
|
227
|
+
*/
|
|
228
|
+
declare function adobe(family: string, kitUrl: string, options?: FontOptions): FontDefinition;
|
|
229
|
+
|
|
230
|
+
export { type CssApiProviderOptions, type FontDefinition, type FontDisplay, type FontFormat, type FontOptions, type FontProvider, type FontProviderContext, type FontStyle, type FontWeight, type FontsPluginOptions, type ParsedFontFace, type ParsedFontSrc, type ResolvedFontFamily, type ResolvedFontFile, type ResolvedFontVariant, adobe, adobeProvider, buildCss2Url, bunny, bunnyProvider, createCssApiProvider, fonts as default, defineFont, fonts, fontshare, fontshareProvider, google, googleProvider, parseFontFaceCss };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
// src/plugin.ts
|
|
2
|
+
import fs3 from "fs";
|
|
3
|
+
import path2 from "path";
|
|
4
|
+
|
|
5
|
+
// src/cache.ts
|
|
6
|
+
import { createHash } from "crypto";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
var DEFAULT_CACHE_DIR = "node_modules/.cache/vite-plugin-fonts";
|
|
10
|
+
function resolveCacheDir(projectRoot, cacheDir) {
|
|
11
|
+
const dir = cacheDir ?? path.resolve(projectRoot, DEFAULT_CACHE_DIR);
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
function cacheKey(input) {
|
|
16
|
+
return createHash("sha256").update(input).digest("hex").slice(0, 16);
|
|
17
|
+
}
|
|
18
|
+
function readCache(cacheDir, key) {
|
|
19
|
+
const filePath = path.join(cacheDir, key);
|
|
20
|
+
return fs.existsSync(filePath) ? fs.readFileSync(filePath) : void 0;
|
|
21
|
+
}
|
|
22
|
+
function writeCache(cacheDir, key, data) {
|
|
23
|
+
fs.writeFileSync(path.join(cacheDir, key), data);
|
|
24
|
+
}
|
|
25
|
+
async function fetchOrThrow(url, init) {
|
|
26
|
+
const response = await fetch(url, init);
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`vite-plugin-fonts: Failed to fetch "${url}": ${response.status} ${response.statusText}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
34
|
+
async function fetchAndCache(url, cacheDir, init) {
|
|
35
|
+
const key = cacheKey(url);
|
|
36
|
+
const cached = readCache(cacheDir, key);
|
|
37
|
+
if (cached) {
|
|
38
|
+
return cached;
|
|
39
|
+
}
|
|
40
|
+
const response = await fetchOrThrow(url, init);
|
|
41
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
42
|
+
writeCache(cacheDir, key, buffer);
|
|
43
|
+
return buffer;
|
|
44
|
+
}
|
|
45
|
+
async function fetchTextAndCache(url, cacheDir, init) {
|
|
46
|
+
const key = cacheKey(url + ":text");
|
|
47
|
+
const cached = readCache(cacheDir, key);
|
|
48
|
+
if (cached) {
|
|
49
|
+
return cached.toString("utf-8");
|
|
50
|
+
}
|
|
51
|
+
const response = await fetchOrThrow(url, init);
|
|
52
|
+
const text = await response.text();
|
|
53
|
+
writeCache(cacheDir, key, text);
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/config.ts
|
|
58
|
+
function familyToSlug(family) {
|
|
59
|
+
return family.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
60
|
+
}
|
|
61
|
+
function defineFont(family, provider, options = {}) {
|
|
62
|
+
const alias = options.alias ?? familyToSlug(family);
|
|
63
|
+
return {
|
|
64
|
+
family,
|
|
65
|
+
provider,
|
|
66
|
+
alias,
|
|
67
|
+
variable: options.variable ?? `--font-${alias}`,
|
|
68
|
+
weights: options.weights ?? [400],
|
|
69
|
+
styles: options.styles ?? ["normal"],
|
|
70
|
+
subsets: options.subsets ?? ["latin"],
|
|
71
|
+
display: options.display ?? "swap",
|
|
72
|
+
preload: options.preload ?? true,
|
|
73
|
+
fallbacks: options.fallbacks ?? []
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function validateFonts(fonts2) {
|
|
77
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
78
|
+
const variables = /* @__PURE__ */ new Set();
|
|
79
|
+
for (const font of fonts2) {
|
|
80
|
+
if (typeof font.family !== "string" || font.family.trim() === "") {
|
|
81
|
+
throw new Error("vite-plugin-fonts: Font family name must be a non-empty string.");
|
|
82
|
+
}
|
|
83
|
+
if (typeof font.alias !== "string" || font.alias.trim() === "") {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`vite-plugin-fonts: Font "${font.family}" has an invalid or empty alias.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (!font.variable.startsWith("--")) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`vite-plugin-fonts: Font "${font.family}" variable "${font.variable}" must start with "--".`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (typeof font.provider?.resolve !== "function") {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`vite-plugin-fonts: Font "${font.family}" has an invalid provider. Use google(), bunny(), defineFont() with a custom provider, or createCssApiProvider().`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (aliases.has(font.alias)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`vite-plugin-fonts: Duplicate font alias "${font.alias}". Each alias must be unique. Use the "alias" option to disambiguate.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
aliases.add(font.alias);
|
|
104
|
+
if (variables.has(font.variable)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`vite-plugin-fonts: Duplicate CSS variable "${font.variable}". Use the "variable" option to set a unique variable name.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
variables.add(font.variable);
|
|
110
|
+
}
|
|
111
|
+
return fonts2;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/css.ts
|
|
115
|
+
var FORMAT_KEYWORDS = {
|
|
116
|
+
woff2: "woff2",
|
|
117
|
+
woff: "woff",
|
|
118
|
+
ttf: "truetype",
|
|
119
|
+
otf: "opentype",
|
|
120
|
+
eot: "embedded-opentype"
|
|
121
|
+
};
|
|
122
|
+
function generateSrc(files, urlMap) {
|
|
123
|
+
return files.map((file) => `url("${urlMap.get(file.source) ?? file.source}") format("${FORMAT_KEYWORDS[file.format]}")`).join(",\n ");
|
|
124
|
+
}
|
|
125
|
+
function generateFontFaces(family, urlMap) {
|
|
126
|
+
const rules = [];
|
|
127
|
+
const { definition } = family;
|
|
128
|
+
for (const variant of family.variants) {
|
|
129
|
+
const rangedFiles = variant.files.filter((f) => f.unicodeRange);
|
|
130
|
+
const nonRangedFiles = variant.files.filter((f) => !f.unicodeRange);
|
|
131
|
+
const rangeGroups = /* @__PURE__ */ new Map();
|
|
132
|
+
for (const file of rangedFiles) {
|
|
133
|
+
const group = rangeGroups.get(file.unicodeRange) ?? [];
|
|
134
|
+
group.push(file);
|
|
135
|
+
rangeGroups.set(file.unicodeRange, group);
|
|
136
|
+
}
|
|
137
|
+
for (const [unicodeRange, files] of rangeGroups) {
|
|
138
|
+
rules.push([
|
|
139
|
+
"@font-face {",
|
|
140
|
+
` font-family: "${definition.family}";`,
|
|
141
|
+
` font-style: ${variant.style};`,
|
|
142
|
+
` font-weight: ${String(variant.weight)};`,
|
|
143
|
+
` font-display: ${definition.display};`,
|
|
144
|
+
` src: ${generateSrc(files, urlMap)};`,
|
|
145
|
+
` unicode-range: ${unicodeRange};`,
|
|
146
|
+
"}"
|
|
147
|
+
].join("\n"));
|
|
148
|
+
}
|
|
149
|
+
if (nonRangedFiles.length > 0) {
|
|
150
|
+
rules.push([
|
|
151
|
+
"@font-face {",
|
|
152
|
+
` font-family: "${definition.family}";`,
|
|
153
|
+
` font-style: ${variant.style};`,
|
|
154
|
+
` font-weight: ${String(variant.weight)};`,
|
|
155
|
+
` font-display: ${definition.display};`,
|
|
156
|
+
` src: ${generateSrc(nonRangedFiles, urlMap)};`,
|
|
157
|
+
"}"
|
|
158
|
+
].join("\n"));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return rules.join("\n\n");
|
|
162
|
+
}
|
|
163
|
+
function familyVariableDeclaration(family) {
|
|
164
|
+
const { definition } = family;
|
|
165
|
+
const parts = [`"${definition.family}"`, ...definition.fallbacks];
|
|
166
|
+
return `${definition.variable}: ${parts.join(", ")};`;
|
|
167
|
+
}
|
|
168
|
+
function generateCssVariables(families) {
|
|
169
|
+
const lines = families.map((family) => ` ${familyVariableDeclaration(family)}`);
|
|
170
|
+
return [":root {", ...lines, "}"].join("\n");
|
|
171
|
+
}
|
|
172
|
+
function generateFontClass(family) {
|
|
173
|
+
const { definition } = family;
|
|
174
|
+
return `.font-${definition.alias} {
|
|
175
|
+
font-family: var(${definition.variable});
|
|
176
|
+
}`;
|
|
177
|
+
}
|
|
178
|
+
function generateFontCss(families, urlMap) {
|
|
179
|
+
const parts = families.map((family) => generateFontFaces(family, urlMap));
|
|
180
|
+
parts.push(generateCssVariables(families));
|
|
181
|
+
parts.push(families.map(generateFontClass).join("\n\n"));
|
|
182
|
+
return parts.join("\n\n") + "\n";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/css-parser.ts
|
|
186
|
+
var FORMAT_ALIASES = {
|
|
187
|
+
woff2: "woff2",
|
|
188
|
+
woff: "woff",
|
|
189
|
+
truetype: "ttf",
|
|
190
|
+
ttf: "ttf",
|
|
191
|
+
opentype: "otf",
|
|
192
|
+
otf: "otf",
|
|
193
|
+
"embedded-opentype": "eot",
|
|
194
|
+
eot: "eot"
|
|
195
|
+
};
|
|
196
|
+
function parseFontFaceCss(css) {
|
|
197
|
+
const results = [];
|
|
198
|
+
const ruleRegex = /(?:\/\*\s*([\w-]+)\s*\*\/\s*)?@font-face\s*\{([^}]+)\}/g;
|
|
199
|
+
let match;
|
|
200
|
+
while ((match = ruleRegex.exec(css)) !== null) {
|
|
201
|
+
const subset = match[1];
|
|
202
|
+
const block = match[2];
|
|
203
|
+
const face = parseFontFaceBlock(block);
|
|
204
|
+
if (face) {
|
|
205
|
+
results.push(subset ? { ...face, subset } : face);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return results;
|
|
209
|
+
}
|
|
210
|
+
function parseFontFaceBlock(block) {
|
|
211
|
+
const family = extractDescriptor(block, "font-family");
|
|
212
|
+
const style = extractDescriptor(block, "font-style");
|
|
213
|
+
const weight = extractDescriptor(block, "font-weight");
|
|
214
|
+
const src = extractDescriptor(block, "src");
|
|
215
|
+
const unicodeRange = extractDescriptor(block, "unicode-range");
|
|
216
|
+
const display = extractDescriptor(block, "font-display");
|
|
217
|
+
if (!family || !src) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const parsedSrc = parseSrcDescriptor(src);
|
|
221
|
+
if (parsedSrc.length === 0) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
family: family.replace(/['"]/g, "").trim(),
|
|
226
|
+
style: style ?? "normal",
|
|
227
|
+
weight: parseWeight(weight ?? "400"),
|
|
228
|
+
src: parsedSrc,
|
|
229
|
+
unicodeRange: unicodeRange ?? void 0,
|
|
230
|
+
display: display ?? void 0
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function extractDescriptor(block, name) {
|
|
234
|
+
const match = new RegExp(`${name}\\s*:\\s*([^;]+)`, "i").exec(block);
|
|
235
|
+
return match?.[1]?.trim() ?? null;
|
|
236
|
+
}
|
|
237
|
+
function parseSrcDescriptor(src) {
|
|
238
|
+
const results = [];
|
|
239
|
+
const urlRegex = /url\(["']?([^"')]+)["']?\)\s*format\(["']?([^"')]+)["']?\)/g;
|
|
240
|
+
let match;
|
|
241
|
+
while ((match = urlRegex.exec(src)) !== null) {
|
|
242
|
+
const format = normalizeFormat(match[2]);
|
|
243
|
+
if (format) {
|
|
244
|
+
results.push({ url: match[1], format });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (results.length === 0) {
|
|
248
|
+
const simpleUrlRegex = /url\(["']?([^"')]+)["']?\)/g;
|
|
249
|
+
while ((match = simpleUrlRegex.exec(src)) !== null) {
|
|
250
|
+
const url = match[1];
|
|
251
|
+
const format = inferFormatFromUrl(url);
|
|
252
|
+
if (format) {
|
|
253
|
+
results.push({ url, format });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
function parseWeight(weight) {
|
|
260
|
+
const trimmed = weight.trim();
|
|
261
|
+
return /^\d+$/.test(trimmed) ? parseInt(trimmed, 10) : trimmed;
|
|
262
|
+
}
|
|
263
|
+
function normalizeFormat(format) {
|
|
264
|
+
return FORMAT_ALIASES[format.toLowerCase()] ?? null;
|
|
265
|
+
}
|
|
266
|
+
function inferFormatFromUrl(url) {
|
|
267
|
+
const ext = url.match(/\.([^.]+)$/)?.[1];
|
|
268
|
+
return ext ? normalizeFormat(ext) : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/dev-server.ts
|
|
272
|
+
import fs2 from "fs";
|
|
273
|
+
|
|
274
|
+
// src/types.ts
|
|
275
|
+
var FORMAT_MIME = {
|
|
276
|
+
woff2: "font/woff2",
|
|
277
|
+
woff: "font/woff",
|
|
278
|
+
ttf: "font/ttf",
|
|
279
|
+
otf: "font/otf",
|
|
280
|
+
eot: "application/vnd.ms-fontobject"
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// src/dev-server.ts
|
|
284
|
+
var DEV_FONT_ROUTE_PREFIX = "/__fonts";
|
|
285
|
+
function buildDevUrlMap(families, fileNames) {
|
|
286
|
+
const urlMap = /* @__PURE__ */ new Map();
|
|
287
|
+
for (const family of families) {
|
|
288
|
+
for (const variant of family.variants) {
|
|
289
|
+
for (const file of variant.files) {
|
|
290
|
+
if (!urlMap.has(file.source)) {
|
|
291
|
+
urlMap.set(file.source, `${DEV_FONT_ROUTE_PREFIX}/${fileNames.get(file.source)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return urlMap;
|
|
297
|
+
}
|
|
298
|
+
function createFontMiddleware() {
|
|
299
|
+
let lookup = /* @__PURE__ */ new Map();
|
|
300
|
+
let ready = Promise.resolve();
|
|
301
|
+
function update(families, fileNames) {
|
|
302
|
+
const newLookup = /* @__PURE__ */ new Map();
|
|
303
|
+
for (const family of families) {
|
|
304
|
+
for (const variant of family.variants) {
|
|
305
|
+
for (const file of variant.files) {
|
|
306
|
+
const name = fileNames.get(file.source);
|
|
307
|
+
if (name) {
|
|
308
|
+
newLookup.set(name, { source: file.source, format: file.format });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
lookup = newLookup;
|
|
314
|
+
}
|
|
315
|
+
function setReady(promise) {
|
|
316
|
+
ready = promise;
|
|
317
|
+
}
|
|
318
|
+
function middleware(req, res, next) {
|
|
319
|
+
if (!req.url?.startsWith(DEV_FONT_ROUTE_PREFIX + "/")) {
|
|
320
|
+
return next();
|
|
321
|
+
}
|
|
322
|
+
ready.then(() => {
|
|
323
|
+
const name = decodeURIComponent(req.url.slice(DEV_FONT_ROUTE_PREFIX.length + 1).split("?")[0]);
|
|
324
|
+
const entry = lookup.get(name);
|
|
325
|
+
if (!entry || !fs2.existsSync(entry.source)) {
|
|
326
|
+
res.statusCode = 404;
|
|
327
|
+
res.end("Font not found");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
res.setHeader("Content-Type", FORMAT_MIME[entry.format] ?? "application/octet-stream");
|
|
331
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
332
|
+
res.setHeader("Cache-Control", "public, max-age=3600");
|
|
333
|
+
const stream = fs2.createReadStream(entry.source);
|
|
334
|
+
stream.on("error", (err) => {
|
|
335
|
+
if (!res.headersSent) {
|
|
336
|
+
res.statusCode = 500;
|
|
337
|
+
}
|
|
338
|
+
res.destroy(err);
|
|
339
|
+
});
|
|
340
|
+
res.on("close", () => {
|
|
341
|
+
stream.destroy();
|
|
342
|
+
});
|
|
343
|
+
stream.pipe(res);
|
|
344
|
+
}, next);
|
|
345
|
+
}
|
|
346
|
+
return { middleware, update, setReady };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/naming.ts
|
|
350
|
+
function assignFileNames(families) {
|
|
351
|
+
const names = /* @__PURE__ */ new Map();
|
|
352
|
+
const used = /* @__PURE__ */ new Map();
|
|
353
|
+
const sourceWeightMap = buildSourceWeightMap(families);
|
|
354
|
+
for (const family of families) {
|
|
355
|
+
const slug = familyToSlug(family.definition.family);
|
|
356
|
+
for (const variant of family.variants) {
|
|
357
|
+
for (const file of variant.files) {
|
|
358
|
+
if (names.has(file.source)) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const shared = (sourceWeightMap.get(file.source)?.get(variant.style)?.size ?? 0) > 1;
|
|
362
|
+
const weight = shared ? "variable" : String(variant.weight).replace(/\s+/g, "-");
|
|
363
|
+
const parts = [slug, weight, variant.style];
|
|
364
|
+
if (file.subset) {
|
|
365
|
+
parts.push(file.subset);
|
|
366
|
+
}
|
|
367
|
+
const ext = `.${file.format}`;
|
|
368
|
+
let name = parts.join("-") + ext;
|
|
369
|
+
const existing = used.get(name);
|
|
370
|
+
if (existing !== void 0 && existing !== file.source) {
|
|
371
|
+
name = `${parts.join("-")}-${cacheKey(file.source).slice(0, 8)}${ext}`;
|
|
372
|
+
}
|
|
373
|
+
used.set(name, file.source);
|
|
374
|
+
names.set(file.source, name);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return names;
|
|
379
|
+
}
|
|
380
|
+
function buildSourceWeightMap(families) {
|
|
381
|
+
const sourceWeightMap = /* @__PURE__ */ new Map();
|
|
382
|
+
for (const family of families) {
|
|
383
|
+
for (const variant of family.variants) {
|
|
384
|
+
for (const file of variant.files) {
|
|
385
|
+
if (!sourceWeightMap.has(file.source)) {
|
|
386
|
+
sourceWeightMap.set(file.source, /* @__PURE__ */ new Map());
|
|
387
|
+
}
|
|
388
|
+
const styleWeights = sourceWeightMap.get(file.source);
|
|
389
|
+
if (!styleWeights.has(variant.style)) {
|
|
390
|
+
styleWeights.set(variant.style, /* @__PURE__ */ new Set());
|
|
391
|
+
}
|
|
392
|
+
styleWeights.get(variant.style).add(String(variant.weight));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return sourceWeightMap;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/plugin.ts
|
|
400
|
+
function withBase(base, filePath) {
|
|
401
|
+
if (base === "") {
|
|
402
|
+
return filePath;
|
|
403
|
+
}
|
|
404
|
+
if (base === "./") {
|
|
405
|
+
return `./${filePath}`;
|
|
406
|
+
}
|
|
407
|
+
return `${base.endsWith("/") ? base : `${base}/`}${filePath}`;
|
|
408
|
+
}
|
|
409
|
+
function createProviderContext(cacheDir, warn) {
|
|
410
|
+
return {
|
|
411
|
+
fetchText: (url, init) => fetchTextAndCache(url, cacheDir, init),
|
|
412
|
+
fetchFile: async (url, init) => {
|
|
413
|
+
await fetchAndCache(url, cacheDir, init);
|
|
414
|
+
return path2.join(cacheDir, cacheKey(url));
|
|
415
|
+
},
|
|
416
|
+
parseFontFaces: parseFontFaceCss,
|
|
417
|
+
warn
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function collectPreloadUrls(families, urlMap) {
|
|
421
|
+
const urls = [];
|
|
422
|
+
const seen = /* @__PURE__ */ new Set();
|
|
423
|
+
for (const family of families) {
|
|
424
|
+
if (family.definition.preload === false) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
for (const variant of family.variants) {
|
|
428
|
+
for (const file of variant.files) {
|
|
429
|
+
if (file.format !== "woff2") {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const url = urlMap.get(file.source);
|
|
433
|
+
if (url && !seen.has(url)) {
|
|
434
|
+
seen.add(url);
|
|
435
|
+
urls.push(url);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return urls;
|
|
441
|
+
}
|
|
442
|
+
function preloadTags(urls) {
|
|
443
|
+
return urls.map((url) => ({
|
|
444
|
+
tag: "link",
|
|
445
|
+
injectTo: "head-prepend",
|
|
446
|
+
attrs: {
|
|
447
|
+
rel: "preload",
|
|
448
|
+
href: url,
|
|
449
|
+
as: "font",
|
|
450
|
+
type: "font/woff2",
|
|
451
|
+
crossorigin: "anonymous"
|
|
452
|
+
}
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
function fonts(options) {
|
|
456
|
+
const definitions = validateFonts(options.fonts ?? []);
|
|
457
|
+
const outputDir = (options.outputDir ?? "fonts").replace(/^\/+|\/+$/g, "");
|
|
458
|
+
const cssFileName = options.cssFileName ?? "fonts.css";
|
|
459
|
+
const inject = options.inject ?? true;
|
|
460
|
+
const dev = options.dev ?? true;
|
|
461
|
+
let config;
|
|
462
|
+
let cacheDir;
|
|
463
|
+
let resolvedFamilies = [];
|
|
464
|
+
let fileNames = /* @__PURE__ */ new Map();
|
|
465
|
+
let buildTags = [];
|
|
466
|
+
let pendingEmissions = null;
|
|
467
|
+
let emitted = false;
|
|
468
|
+
let devReady = Promise.resolve();
|
|
469
|
+
let devFailed = false;
|
|
470
|
+
async function resolveFamilies(warn) {
|
|
471
|
+
const context = createProviderContext(cacheDir, warn);
|
|
472
|
+
const families = [];
|
|
473
|
+
for (const definition of definitions) {
|
|
474
|
+
families.push({
|
|
475
|
+
definition,
|
|
476
|
+
variants: await definition.provider.resolve(definition, context)
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
resolvedFamilies = families;
|
|
480
|
+
fileNames = assignFileNames(families);
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
name: "vite-plugin-fonts",
|
|
484
|
+
configResolved(resolved) {
|
|
485
|
+
config = resolved;
|
|
486
|
+
cacheDir = resolveCacheDir(resolved.root, options.cacheDir);
|
|
487
|
+
},
|
|
488
|
+
async buildStart() {
|
|
489
|
+
if (config.command !== "build" || config.build.ssr || definitions.length === 0) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
await resolveFamilies((message) => this.warn(message));
|
|
493
|
+
const cssUrlMap = /* @__PURE__ */ new Map();
|
|
494
|
+
const publicUrlMap = /* @__PURE__ */ new Map();
|
|
495
|
+
for (const [source, name] of fileNames) {
|
|
496
|
+
cssUrlMap.set(source, `./${name}`);
|
|
497
|
+
publicUrlMap.set(source, withBase(config.base, `${outputDir}/${name}`));
|
|
498
|
+
}
|
|
499
|
+
const css = generateFontCss(resolvedFamilies, cssUrlMap);
|
|
500
|
+
const cssPath = `${outputDir}/${cssFileName}`;
|
|
501
|
+
buildTags = [
|
|
502
|
+
...preloadTags(collectPreloadUrls(resolvedFamilies, publicUrlMap)),
|
|
503
|
+
{
|
|
504
|
+
tag: "link",
|
|
505
|
+
injectTo: "head",
|
|
506
|
+
attrs: { rel: "stylesheet", href: withBase(config.base, cssPath) }
|
|
507
|
+
}
|
|
508
|
+
];
|
|
509
|
+
pendingEmissions = { css, cssPath };
|
|
510
|
+
},
|
|
511
|
+
generateBundle() {
|
|
512
|
+
if (config.command !== "build" || config.build.ssr || !pendingEmissions) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const emittedSources = /* @__PURE__ */ new Set();
|
|
516
|
+
for (const family of resolvedFamilies) {
|
|
517
|
+
for (const variant of family.variants) {
|
|
518
|
+
for (const file of variant.files) {
|
|
519
|
+
if (emittedSources.has(file.source)) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
emittedSources.add(file.source);
|
|
523
|
+
this.emitFile({
|
|
524
|
+
type: "asset",
|
|
525
|
+
fileName: `${outputDir}/${fileNames.get(file.source)}`,
|
|
526
|
+
source: fs3.readFileSync(file.source)
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
this.emitFile({
|
|
532
|
+
type: "asset",
|
|
533
|
+
fileName: pendingEmissions.cssPath,
|
|
534
|
+
source: pendingEmissions.css
|
|
535
|
+
});
|
|
536
|
+
emitted = true;
|
|
537
|
+
},
|
|
538
|
+
transformIndexHtml: {
|
|
539
|
+
order: "post",
|
|
540
|
+
async handler() {
|
|
541
|
+
if (!inject || definitions.length === 0) {
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
if (config.command === "build") {
|
|
545
|
+
return emitted ? buildTags : [];
|
|
546
|
+
}
|
|
547
|
+
if (!dev) {
|
|
548
|
+
return [];
|
|
549
|
+
}
|
|
550
|
+
await devReady;
|
|
551
|
+
if (devFailed || resolvedFamilies.length === 0) {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
const urlMap = buildDevUrlMap(resolvedFamilies, fileNames);
|
|
555
|
+
return [
|
|
556
|
+
...preloadTags(collectPreloadUrls(resolvedFamilies, urlMap)),
|
|
557
|
+
{
|
|
558
|
+
tag: "style",
|
|
559
|
+
injectTo: "head",
|
|
560
|
+
children: generateFontCss(resolvedFamilies, urlMap)
|
|
561
|
+
}
|
|
562
|
+
];
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
configureServer(server) {
|
|
566
|
+
if (!dev || definitions.length === 0) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const fontMiddleware = createFontMiddleware();
|
|
570
|
+
server.middlewares.use(fontMiddleware.middleware);
|
|
571
|
+
const ready = (async () => {
|
|
572
|
+
try {
|
|
573
|
+
await resolveFamilies((message) => server.config.logger.warn(`[vite-plugin-fonts] ${message}`));
|
|
574
|
+
fontMiddleware.update(resolvedFamilies, fileNames);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
devFailed = true;
|
|
577
|
+
server.config.logger.error(`[vite-plugin-fonts] ${error.message}`);
|
|
578
|
+
}
|
|
579
|
+
})();
|
|
580
|
+
fontMiddleware.setReady(ready);
|
|
581
|
+
devReady = ready;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// src/providers/css-api.ts
|
|
587
|
+
var WOFF2_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
588
|
+
var FORMAT_PREFERENCE = ["woff2", "woff", "ttf", "otf", "eot"];
|
|
589
|
+
function buildCss2Url(definition, baseUrl) {
|
|
590
|
+
const family = definition.family.replace(/ /g, "+");
|
|
591
|
+
const hasItalic = definition.styles.includes("italic");
|
|
592
|
+
const axes = hasItalic ? ["ital", "wght"] : ["wght"];
|
|
593
|
+
const tuples = /* @__PURE__ */ new Set();
|
|
594
|
+
for (const weight of definition.weights) {
|
|
595
|
+
for (const style of definition.styles) {
|
|
596
|
+
tuples.add(hasItalic ? `${style === "italic" ? "1" : "0"},${weight}` : `${weight}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const axisStr = axes.join(",");
|
|
600
|
+
const tupleStr = [...tuples].sort().join(";");
|
|
601
|
+
return `${baseUrl}?family=${family}:${axisStr}@${tupleStr}&display=${definition.display}`;
|
|
602
|
+
}
|
|
603
|
+
function createCssApiProvider(options) {
|
|
604
|
+
const buildUrl = options.buildUrl ?? buildCss2Url;
|
|
605
|
+
return {
|
|
606
|
+
name: options.name,
|
|
607
|
+
async resolve(definition, context) {
|
|
608
|
+
const url = buildUrl(definition, options.baseUrl);
|
|
609
|
+
const headers = typeof options.headers === "function" ? options.headers(definition) : options.headers ?? { "User-Agent": WOFF2_USER_AGENT };
|
|
610
|
+
const css = await context.fetchText(url, { headers });
|
|
611
|
+
let faces = context.parseFontFaces(css);
|
|
612
|
+
if (options.transformFaces) {
|
|
613
|
+
faces = options.transformFaces(faces, definition);
|
|
614
|
+
}
|
|
615
|
+
faces = filterFaces(faces, definition, options.name, options.filterSubsets ?? true);
|
|
616
|
+
return downloadFaces(faces, definition, context, headers, options.formats);
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
function filterFaces(faces, definition, providerName, filterSubsets) {
|
|
621
|
+
if (faces.length === 0) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`vite-plugin-fonts: ${providerName} returned no @font-face rules for "${definition.family}". Check the family name and requested weights/styles.`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const familyFaces = faces.filter(
|
|
627
|
+
(face) => face.family.toLowerCase() === definition.family.toLowerCase()
|
|
628
|
+
);
|
|
629
|
+
if (familyFaces.length === 0) {
|
|
630
|
+
const available = [...new Set(faces.map((face) => face.family))];
|
|
631
|
+
throw new Error(
|
|
632
|
+
`vite-plugin-fonts: ${providerName} returned no @font-face rules for family "${definition.family}". Available families: [${available.join(", ")}].`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
const requestedWeights = definition.weights.map(String);
|
|
636
|
+
const requestedWeightsAreNumeric = requestedWeights.every((weight) => /^\d+$/.test(weight));
|
|
637
|
+
const variantFaces = familyFaces.filter((face) => {
|
|
638
|
+
if (!definition.styles.includes(face.style)) {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
if (requestedWeightsAreNumeric && /^\d+$/.test(String(face.weight))) {
|
|
642
|
+
return requestedWeights.includes(String(face.weight));
|
|
643
|
+
}
|
|
644
|
+
return true;
|
|
645
|
+
});
|
|
646
|
+
if (variantFaces.length === 0) {
|
|
647
|
+
const availableWeights = [...new Set(familyFaces.map((face) => String(face.weight)))];
|
|
648
|
+
const availableStyles = [...new Set(familyFaces.map((face) => face.style))];
|
|
649
|
+
throw new Error(
|
|
650
|
+
`vite-plugin-fonts: ${providerName} returned no @font-face rules matching the requested weights [${requestedWeights.join(", ")}] and styles [${definition.styles.join(", ")}] for "${definition.family}". Available weights: [${availableWeights.join(", ")}], styles: [${availableStyles.join(", ")}].`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
if (!filterSubsets) {
|
|
654
|
+
return variantFaces.map((face) => ({ ...face, subset: void 0 }));
|
|
655
|
+
}
|
|
656
|
+
const subsetFaces = variantFaces.filter(
|
|
657
|
+
(face) => !face.subset || definition.subsets.includes(face.subset)
|
|
658
|
+
);
|
|
659
|
+
if (subsetFaces.length === 0) {
|
|
660
|
+
const available = [...new Set(familyFaces.map((face) => face.subset).filter(Boolean))];
|
|
661
|
+
throw new Error(
|
|
662
|
+
`vite-plugin-fonts: ${providerName} returned no @font-face rules matching the requested subsets [${definition.subsets.join(", ")}] for "${definition.family}". Available subsets: [${available.join(", ")}].`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
return subsetFaces;
|
|
666
|
+
}
|
|
667
|
+
async function downloadFaces(faces, definition, context, headers, formats) {
|
|
668
|
+
const variants = [];
|
|
669
|
+
for (const face of faces) {
|
|
670
|
+
const files = [];
|
|
671
|
+
for (const src of face.src) {
|
|
672
|
+
if (formats && !formats.includes(src.format)) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const url = src.url.startsWith("//") ? `https:${src.url}` : src.url;
|
|
676
|
+
files.push({
|
|
677
|
+
source: await context.fetchFile(url, { headers }),
|
|
678
|
+
format: src.format,
|
|
679
|
+
unicodeRange: face.unicodeRange,
|
|
680
|
+
subset: face.subset
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
if (files.length === 0) {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
files.sort((a, b) => FORMAT_PREFERENCE.indexOf(a.format) - FORMAT_PREFERENCE.indexOf(b.format));
|
|
687
|
+
variants.push({ weight: face.weight, style: face.style, files });
|
|
688
|
+
}
|
|
689
|
+
return sortVariants(variants);
|
|
690
|
+
}
|
|
691
|
+
function sortVariants(variants) {
|
|
692
|
+
const weight = (value) => {
|
|
693
|
+
const parsed = parseInt(String(value), 10);
|
|
694
|
+
return Number.isNaN(parsed) ? 400 : parsed;
|
|
695
|
+
};
|
|
696
|
+
return variants.sort((a, b) => {
|
|
697
|
+
if (weight(a.weight) !== weight(b.weight)) {
|
|
698
|
+
return weight(a.weight) - weight(b.weight);
|
|
699
|
+
}
|
|
700
|
+
if (a.style !== b.style) {
|
|
701
|
+
return a.style.localeCompare(b.style);
|
|
702
|
+
}
|
|
703
|
+
const rangeA = a.files[0]?.unicodeRange ?? "";
|
|
704
|
+
const rangeB = b.files[0]?.unicodeRange ?? "";
|
|
705
|
+
return rangeA.localeCompare(rangeB);
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/providers/index.ts
|
|
710
|
+
var googleProvider = createCssApiProvider({
|
|
711
|
+
name: "google",
|
|
712
|
+
baseUrl: "https://fonts.googleapis.com/css2"
|
|
713
|
+
});
|
|
714
|
+
var bunnyProvider = createCssApiProvider({
|
|
715
|
+
name: "bunny",
|
|
716
|
+
baseUrl: "https://fonts.bunny.net/css2"
|
|
717
|
+
});
|
|
718
|
+
var fontshareProvider = createCssApiProvider({
|
|
719
|
+
name: "fontshare",
|
|
720
|
+
baseUrl: "https://api.fontshare.com/v2/css",
|
|
721
|
+
buildUrl: (definition, baseUrl) => `${baseUrl}?f[]=${definition.family.toLowerCase().replace(/ /g, "-")}@${definition.weights.join(",")}&display=${definition.display}`,
|
|
722
|
+
// Fontshare labels its rules with the family name instead of a subset.
|
|
723
|
+
filterSubsets: false,
|
|
724
|
+
formats: ["woff2"]
|
|
725
|
+
});
|
|
726
|
+
function adobeProvider(kitUrl) {
|
|
727
|
+
return createCssApiProvider({
|
|
728
|
+
name: "adobe",
|
|
729
|
+
baseUrl: kitUrl,
|
|
730
|
+
buildUrl: (_definition, baseUrl) => baseUrl,
|
|
731
|
+
headers: {}
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function google(family, options) {
|
|
735
|
+
return defineFont(family, googleProvider, options);
|
|
736
|
+
}
|
|
737
|
+
function bunny(family, options) {
|
|
738
|
+
return defineFont(family, bunnyProvider, options);
|
|
739
|
+
}
|
|
740
|
+
function fontshare(family, options) {
|
|
741
|
+
return defineFont(family, fontshareProvider, options);
|
|
742
|
+
}
|
|
743
|
+
function adobe(family, kitUrl, options) {
|
|
744
|
+
return defineFont(family, adobeProvider(kitUrl), options);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/index.ts
|
|
748
|
+
var index_default = fonts;
|
|
749
|
+
export {
|
|
750
|
+
adobe,
|
|
751
|
+
adobeProvider,
|
|
752
|
+
buildCss2Url,
|
|
753
|
+
bunny,
|
|
754
|
+
bunnyProvider,
|
|
755
|
+
createCssApiProvider,
|
|
756
|
+
index_default as default,
|
|
757
|
+
defineFont,
|
|
758
|
+
fonts,
|
|
759
|
+
fontshare,
|
|
760
|
+
fontshareProvider,
|
|
761
|
+
google,
|
|
762
|
+
googleProvider,
|
|
763
|
+
parseFontFaceCss
|
|
764
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vite-plugin-local-webfonts",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Download web fonts (Google Fonts, Bunny Fonts, and more) into your Vite build output",
|
|
5
|
+
"author": "NietThijmen <53520119+NietThijmen@users.noreply.github.com>",
|
|
6
|
+
"homepage": "https://github.com/NietThijmen/vite-fonts#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/NietThijmen/vite-fonts.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/NietThijmen/vite-fonts/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"vite",
|
|
16
|
+
"plugin",
|
|
17
|
+
"fonts",
|
|
18
|
+
"google-fonts",
|
|
19
|
+
"bunny-fonts",
|
|
20
|
+
"fontshare",
|
|
21
|
+
"adobe-fonts",
|
|
22
|
+
"webfonts",
|
|
23
|
+
"download",
|
|
24
|
+
"privacy",
|
|
25
|
+
"performance"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"main": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"files": [
|
|
38
|
+
"dist"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsup",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"vite": ">=5.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^26.4.1",
|
|
51
|
+
"tsup": "^8.3.5",
|
|
52
|
+
"typescript": "^5.7.3",
|
|
53
|
+
"vite": "^6.0.7",
|
|
54
|
+
"vitest": "^3.0.5"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18.0.0"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|