fontaine 0.0.1 → 0.0.2
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/LICENCE +21 -0
- package/README.md +137 -0
- package/dist/index.cjs +172 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.mjs +164 -0
- package/package.json +79 -6
package/LICENCE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Daniel Roe
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# fontaine
|
|
2
|
+
|
|
3
|
+
[![npm version][npm-version-src]][npm-version-href]
|
|
4
|
+
[![npm downloads][npm-downloads-src]][npm-downloads-href]
|
|
5
|
+
[![Github Actions][github-actions-src]][github-actions-href]
|
|
6
|
+
[![Codecov][codecov-src]][codecov-href]
|
|
7
|
+
|
|
8
|
+
> Automatic font fallback based on font metrics
|
|
9
|
+
|
|
10
|
+
- [✨ Changelog](https://github.com/danielroe/nuxt-font-metrics/blob/main/CHANGELOG.md)
|
|
11
|
+
- [▶️ Online playground](https://stackblitz.com/github/danielroe/nuxt-font-metrics/tree/main/playground)
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
**⚠️ `fontaine` is under active development. ⚠️**
|
|
16
|
+
|
|
17
|
+
- 💪 Reduces CLS by using local font fallbacks with crafted font metrics.
|
|
18
|
+
- ✨ Generates font metrics and overrides automatically.
|
|
19
|
+
- ⚡️ Pure CSS, zero runtime overhead.
|
|
20
|
+
|
|
21
|
+
On the playground project, enabling/disabling `fontaine` makes the following difference rendering `/`, with no customisation required:
|
|
22
|
+
|
|
23
|
+
| | Before | After |
|
|
24
|
+
| ----------- | ------ | ------- |
|
|
25
|
+
| CLS | `0.24` | `0.054` |
|
|
26
|
+
| Performance | `92` | `100` |
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
With `pnpm`
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm add -D fontaine
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or, with `npm`
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -D fontaine
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or, with `yarn`
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
yarn add -D fontaine
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { FontaineTransform } from 'fontaine'
|
|
52
|
+
|
|
53
|
+
const options = {
|
|
54
|
+
fallbacks: ['BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'Noto Sans'],
|
|
55
|
+
// You may need to resolve assets like `/fonts/Roboto.woff2` to a particular directory
|
|
56
|
+
resolvePath: (id) => 'file:///path/to/public/dir' + id,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Vite
|
|
60
|
+
export default {
|
|
61
|
+
plugins: [FontaineTransform.vite(options)]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Next.js
|
|
65
|
+
export default {
|
|
66
|
+
webpack(config) {
|
|
67
|
+
config.plugins = config.plugins || []
|
|
68
|
+
config.plugins.push(FontaineTransform.webpack(options))
|
|
69
|
+
return config
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
`fontaine` will scan your `@font-face` rules and generate fallback rules with the correct metrics. For example:
|
|
77
|
+
|
|
78
|
+
```css
|
|
79
|
+
@font-face {
|
|
80
|
+
font-family: 'Roboto';
|
|
81
|
+
font-display: swap;
|
|
82
|
+
src: url('/fonts/Roboto.woff2') format('woff2'), url('/fonts/Roboto.woff')
|
|
83
|
+
format('woff');
|
|
84
|
+
font-weight: 700;
|
|
85
|
+
}
|
|
86
|
+
/* This will be generated. */
|
|
87
|
+
@font-face {
|
|
88
|
+
font-family: 'Roboto override';
|
|
89
|
+
src: local('BlinkMacSystemFont'), local('Segoe UI'), local('Roboto'), local(
|
|
90
|
+
'Helvetica Neue'
|
|
91
|
+
), local('Arial'), local('Noto Sans');
|
|
92
|
+
ascent-override: 92.7734375%;
|
|
93
|
+
descent-override: 24.4140625%;
|
|
94
|
+
line-gap-override: 0%;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Then, whenever you use `font-family: 'Roboto'`, `fontaine` will add the override to the font-family:
|
|
99
|
+
|
|
100
|
+
```css
|
|
101
|
+
:root {
|
|
102
|
+
font-family: 'Roboto';
|
|
103
|
+
/* This becomes */
|
|
104
|
+
font-family: 'Roboto', 'Roboto override';
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## 💻 Development
|
|
109
|
+
|
|
110
|
+
- Clone this repository
|
|
111
|
+
- Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` (use `npm i -g corepack` for Node.js < 16.10)
|
|
112
|
+
- Install dependencies using `pnpm install`
|
|
113
|
+
- Run interactive tests using `pnpm dev`; launch a vite server using source code with `pnpm demo:dev`
|
|
114
|
+
|
|
115
|
+
## Credits
|
|
116
|
+
|
|
117
|
+
This would not have been possible without:
|
|
118
|
+
|
|
119
|
+
- amazing tooling and generated metrics from [capsizecss](https://seek-oss.github.io/capsize/)
|
|
120
|
+
- suggestion and algorithm from [Katie Hempenius](https://katiehempenius.com/) & [Kara Erickson](https://github.com/kara) on the Google Aurora team - see [notes on calculating font metric overrides](https://docs.google.com/document/d/e/2PACX-1vRsazeNirATC7lIj2aErSHpK26hZ6dA9GsQ069GEbq5fyzXEhXbvByoftSfhG82aJXmrQ_sJCPBqcx_/pub).
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
Made with ❤️
|
|
125
|
+
|
|
126
|
+
Published under [MIT License](./LICENCE).
|
|
127
|
+
|
|
128
|
+
<!-- Badges -->
|
|
129
|
+
|
|
130
|
+
[npm-version-src]: https://img.shields.io/npm/v/fontaine?style=flat-square
|
|
131
|
+
[npm-version-href]: https://npmjs.com/package/fontaine
|
|
132
|
+
[npm-downloads-src]: https://img.shields.io/npm/dm/fontaine?style=flat-square
|
|
133
|
+
[npm-downloads-href]: https://npmjs.com/package/fontaine
|
|
134
|
+
[github-actions-src]: https://img.shields.io/github/workflow/status/danielroe/fontaine/ci/main?style=flat-square
|
|
135
|
+
[github-actions-href]: https://github.com/danielroe/fontaine/actions?query=workflow%3Aci
|
|
136
|
+
[codecov-src]: https://img.shields.io/codecov/c/gh/danielroe/fontaine/main?style=flat-square
|
|
137
|
+
[codecov-href]: https://codecov.io/gh/danielroe/fontaine
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
const unplugin = require('unplugin');
|
|
6
|
+
const magicRegexp = require('magic-regexp');
|
|
7
|
+
const MagicString = require('magic-string');
|
|
8
|
+
const node_url = require('node:url');
|
|
9
|
+
const unpack = require('@capsizecss/unpack');
|
|
10
|
+
const ufo = require('ufo');
|
|
11
|
+
const scule = require('scule');
|
|
12
|
+
|
|
13
|
+
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e["default"] : e; }
|
|
14
|
+
|
|
15
|
+
const MagicString__default = /*#__PURE__*/_interopDefaultLegacy(MagicString);
|
|
16
|
+
|
|
17
|
+
const parseFontFace = (css) => {
|
|
18
|
+
const fontFamily = css.match(FAMILY_RE)?.groups.fontFamily;
|
|
19
|
+
const src = css.match(SOURCE_RE)?.groups.src;
|
|
20
|
+
const family = withoutQuotes(fontFamily?.split(",")[0] || "");
|
|
21
|
+
const source = withoutQuotes(
|
|
22
|
+
src?.split(",").map((source2) => source2.match(URL_RE)?.groups.url).filter(Boolean)[0] || ""
|
|
23
|
+
);
|
|
24
|
+
return { family, source };
|
|
25
|
+
};
|
|
26
|
+
const generateOverrideName = (name) => {
|
|
27
|
+
const firstFamily = withoutQuotes(name.split(",").shift());
|
|
28
|
+
return `${firstFamily} override`;
|
|
29
|
+
};
|
|
30
|
+
const withoutQuotes = (str) => str.trim().replace(QUOTES_RE, "");
|
|
31
|
+
const generateFontFace = (metrics, options) => {
|
|
32
|
+
const { name, fallbacks, ...properties } = options;
|
|
33
|
+
const sizeAdjust = 1;
|
|
34
|
+
const declaration = {
|
|
35
|
+
"font-family": JSON.stringify(name),
|
|
36
|
+
src: fallbacks.map((f) => `local(${JSON.stringify(f)})`),
|
|
37
|
+
"ascent-override": toPercentage(
|
|
38
|
+
metrics.ascent / (metrics.unitsPerEm * sizeAdjust)
|
|
39
|
+
),
|
|
40
|
+
"descent-override": toPercentage(
|
|
41
|
+
Math.abs(metrics.descent / (metrics.unitsPerEm * sizeAdjust))
|
|
42
|
+
),
|
|
43
|
+
"line-gap-override": toPercentage(
|
|
44
|
+
metrics.lineGap / (metrics.unitsPerEm * sizeAdjust)
|
|
45
|
+
),
|
|
46
|
+
...properties
|
|
47
|
+
};
|
|
48
|
+
return `@font-face {
|
|
49
|
+
${toCSS(declaration)}
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
};
|
|
53
|
+
const toPercentage = (value, fractionDigits = 8) => {
|
|
54
|
+
const percentage = value * 100;
|
|
55
|
+
return (percentage % 1 ? percentage.toFixed(fractionDigits).replace(/0+$/, "") : percentage) + "%";
|
|
56
|
+
};
|
|
57
|
+
const toCSS = (properties, indent = 2) => Object.entries(properties).map(([key, value]) => " ".repeat(indent) + `${key}: ${value};`).join("\n");
|
|
58
|
+
const QUOTES_RE = magicRegexp.createRegExp(
|
|
59
|
+
magicRegexp.charIn(`"'`).at.lineStart().or(magicRegexp.charIn(`"'`).at.lineEnd()),
|
|
60
|
+
["g"]
|
|
61
|
+
);
|
|
62
|
+
const FAMILY_RE = magicRegexp.createRegExp(
|
|
63
|
+
magicRegexp.exactly("font-family:").and(magicRegexp.whitespace.optionally()).and(magicRegexp.charNotIn(";}").times.any().as("fontFamily"))
|
|
64
|
+
);
|
|
65
|
+
const SOURCE_RE = magicRegexp.createRegExp(
|
|
66
|
+
magicRegexp.exactly("src:").and(magicRegexp.whitespace.optionally()).and(magicRegexp.charNotIn(";}").times.any().as("src"))
|
|
67
|
+
);
|
|
68
|
+
const URL_RE = magicRegexp.createRegExp(
|
|
69
|
+
magicRegexp.exactly("url(").and(magicRegexp.charNotIn(")").times.any().as("url")).and(")")
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const metricCache = {};
|
|
73
|
+
async function getMetricsForFamily(family) {
|
|
74
|
+
family = withoutQuotes(family);
|
|
75
|
+
if (family in metricCache)
|
|
76
|
+
return metricCache[family];
|
|
77
|
+
try {
|
|
78
|
+
const name = scule.camelCase(family).replace(/ /, "");
|
|
79
|
+
const metrics = await import(`@capsizecss/metrics/${name}.js`).then(
|
|
80
|
+
(r) => r.default || r
|
|
81
|
+
);
|
|
82
|
+
metricCache[family] = metrics;
|
|
83
|
+
return metrics;
|
|
84
|
+
} catch {
|
|
85
|
+
metricCache[family] = null;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function readMetrics(_source) {
|
|
90
|
+
const source = typeof _source !== "string" && "href" in _source ? _source.href : _source;
|
|
91
|
+
if (source in metricCache) {
|
|
92
|
+
return Promise.resolve(metricCache[source]);
|
|
93
|
+
}
|
|
94
|
+
const { protocol } = ufo.parseURL(source);
|
|
95
|
+
if (!protocol)
|
|
96
|
+
return null;
|
|
97
|
+
const metrics = protocol === "file:" ? await unpack.fromFile(node_url.fileURLToPath(source)) : await unpack.fromUrl(source);
|
|
98
|
+
metricCache[source] = metrics;
|
|
99
|
+
return metrics;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const FontaineTransform = unplugin.createUnplugin(
|
|
103
|
+
(options) => {
|
|
104
|
+
const cssContext = options.css = options.css || {};
|
|
105
|
+
cssContext.value = "";
|
|
106
|
+
return {
|
|
107
|
+
name: "nuxt-font-metrics-transform",
|
|
108
|
+
enforce: "pre",
|
|
109
|
+
transformInclude(id) {
|
|
110
|
+
const { pathname } = ufo.parseURL(id);
|
|
111
|
+
return pathname.endsWith(".css");
|
|
112
|
+
},
|
|
113
|
+
async transform(code, id) {
|
|
114
|
+
const s = new MagicString__default(code);
|
|
115
|
+
const faceRanges = [];
|
|
116
|
+
for (const match of code.matchAll(FONT_FACE_RE)) {
|
|
117
|
+
const matchContent = match[0];
|
|
118
|
+
if (match.index === void 0 || !matchContent)
|
|
119
|
+
continue;
|
|
120
|
+
faceRanges.push([match.index, match.index + matchContent.length]);
|
|
121
|
+
const { family, source } = parseFontFace(matchContent);
|
|
122
|
+
if (!family)
|
|
123
|
+
continue;
|
|
124
|
+
const metrics = await getMetricsForFamily(family) || (source ? await readMetrics(
|
|
125
|
+
options.resolvePath ? options.resolvePath(source) : source
|
|
126
|
+
).catch(() => null) : null);
|
|
127
|
+
if (metrics) {
|
|
128
|
+
const fontFace = generateFontFace(metrics, {
|
|
129
|
+
name: generateOverrideName(family),
|
|
130
|
+
fallbacks: options.fallbacks
|
|
131
|
+
});
|
|
132
|
+
cssContext.value += fontFace;
|
|
133
|
+
s.appendLeft(match.index, fontFace);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const match of code.matchAll(FONT_FAMILY_RE)) {
|
|
137
|
+
const { index, 0: matchContent } = match;
|
|
138
|
+
if (index === void 0 || !matchContent)
|
|
139
|
+
continue;
|
|
140
|
+
if (faceRanges.some(([start, end]) => index > start && index < end))
|
|
141
|
+
continue;
|
|
142
|
+
const families = matchContent.split(",").map((f) => f.trim());
|
|
143
|
+
s.overwrite(
|
|
144
|
+
index,
|
|
145
|
+
index + matchContent.length,
|
|
146
|
+
" " + [
|
|
147
|
+
families[0],
|
|
148
|
+
`"${generateOverrideName(families[0])}"`,
|
|
149
|
+
...families.slice(1)
|
|
150
|
+
].join(", ")
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (s.hasChanged()) {
|
|
154
|
+
return {
|
|
155
|
+
code: s.toString(),
|
|
156
|
+
map: options.sourcemap ? s.generateMap({ source: id, includeContent: true }) : void 0
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
const FONT_FACE_RE = magicRegexp.createRegExp(
|
|
164
|
+
magicRegexp.exactly("@font-face").and(magicRegexp.whitespace.times.any()).and("{").and(magicRegexp.charNotIn("}").times.any()).and("}"),
|
|
165
|
+
["g"]
|
|
166
|
+
);
|
|
167
|
+
const FONT_FAMILY_RE = magicRegexp.createRegExp(
|
|
168
|
+
magicRegexp.charNotIn(";}").times.any().as("family").after(magicRegexp.exactly("font-family:").and(magicRegexp.whitespace.times.any())).before(magicRegexp.charIn(";}").or(magicRegexp.exactly("").at.lineEnd())),
|
|
169
|
+
["g"]
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
exports.FontaineTransform = FontaineTransform;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as unplugin from 'unplugin';
|
|
2
|
+
|
|
3
|
+
interface FontaineTransformOptions {
|
|
4
|
+
css?: {
|
|
5
|
+
value?: string;
|
|
6
|
+
};
|
|
7
|
+
fallbacks: string[];
|
|
8
|
+
resolvePath?: (path: string) => string | URL;
|
|
9
|
+
sourcemap?: boolean;
|
|
10
|
+
}
|
|
11
|
+
declare const FontaineTransform: unplugin.UnpluginInstance<FontaineTransformOptions>;
|
|
12
|
+
|
|
13
|
+
export { FontaineTransform };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createUnplugin } from 'unplugin';
|
|
2
|
+
import { createRegExp, charIn, exactly, whitespace, charNotIn } from 'magic-regexp';
|
|
3
|
+
import MagicString from 'magic-string';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { fromFile, fromUrl } from '@capsizecss/unpack';
|
|
6
|
+
import { parseURL } from 'ufo';
|
|
7
|
+
import { camelCase } from 'scule';
|
|
8
|
+
|
|
9
|
+
const parseFontFace = (css) => {
|
|
10
|
+
const fontFamily = css.match(FAMILY_RE)?.groups.fontFamily;
|
|
11
|
+
const src = css.match(SOURCE_RE)?.groups.src;
|
|
12
|
+
const family = withoutQuotes(fontFamily?.split(",")[0] || "");
|
|
13
|
+
const source = withoutQuotes(
|
|
14
|
+
src?.split(",").map((source2) => source2.match(URL_RE)?.groups.url).filter(Boolean)[0] || ""
|
|
15
|
+
);
|
|
16
|
+
return { family, source };
|
|
17
|
+
};
|
|
18
|
+
const generateOverrideName = (name) => {
|
|
19
|
+
const firstFamily = withoutQuotes(name.split(",").shift());
|
|
20
|
+
return `${firstFamily} override`;
|
|
21
|
+
};
|
|
22
|
+
const withoutQuotes = (str) => str.trim().replace(QUOTES_RE, "");
|
|
23
|
+
const generateFontFace = (metrics, options) => {
|
|
24
|
+
const { name, fallbacks, ...properties } = options;
|
|
25
|
+
const sizeAdjust = 1;
|
|
26
|
+
const declaration = {
|
|
27
|
+
"font-family": JSON.stringify(name),
|
|
28
|
+
src: fallbacks.map((f) => `local(${JSON.stringify(f)})`),
|
|
29
|
+
"ascent-override": toPercentage(
|
|
30
|
+
metrics.ascent / (metrics.unitsPerEm * sizeAdjust)
|
|
31
|
+
),
|
|
32
|
+
"descent-override": toPercentage(
|
|
33
|
+
Math.abs(metrics.descent / (metrics.unitsPerEm * sizeAdjust))
|
|
34
|
+
),
|
|
35
|
+
"line-gap-override": toPercentage(
|
|
36
|
+
metrics.lineGap / (metrics.unitsPerEm * sizeAdjust)
|
|
37
|
+
),
|
|
38
|
+
...properties
|
|
39
|
+
};
|
|
40
|
+
return `@font-face {
|
|
41
|
+
${toCSS(declaration)}
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
};
|
|
45
|
+
const toPercentage = (value, fractionDigits = 8) => {
|
|
46
|
+
const percentage = value * 100;
|
|
47
|
+
return (percentage % 1 ? percentage.toFixed(fractionDigits).replace(/0+$/, "") : percentage) + "%";
|
|
48
|
+
};
|
|
49
|
+
const toCSS = (properties, indent = 2) => Object.entries(properties).map(([key, value]) => " ".repeat(indent) + `${key}: ${value};`).join("\n");
|
|
50
|
+
const QUOTES_RE = createRegExp(
|
|
51
|
+
charIn(`"'`).at.lineStart().or(charIn(`"'`).at.lineEnd()),
|
|
52
|
+
["g"]
|
|
53
|
+
);
|
|
54
|
+
const FAMILY_RE = createRegExp(
|
|
55
|
+
exactly("font-family:").and(whitespace.optionally()).and(charNotIn(";}").times.any().as("fontFamily"))
|
|
56
|
+
);
|
|
57
|
+
const SOURCE_RE = createRegExp(
|
|
58
|
+
exactly("src:").and(whitespace.optionally()).and(charNotIn(";}").times.any().as("src"))
|
|
59
|
+
);
|
|
60
|
+
const URL_RE = createRegExp(
|
|
61
|
+
exactly("url(").and(charNotIn(")").times.any().as("url")).and(")")
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const metricCache = {};
|
|
65
|
+
async function getMetricsForFamily(family) {
|
|
66
|
+
family = withoutQuotes(family);
|
|
67
|
+
if (family in metricCache)
|
|
68
|
+
return metricCache[family];
|
|
69
|
+
try {
|
|
70
|
+
const name = camelCase(family).replace(/ /, "");
|
|
71
|
+
const metrics = await import(`@capsizecss/metrics/${name}.js`).then(
|
|
72
|
+
(r) => r.default || r
|
|
73
|
+
);
|
|
74
|
+
metricCache[family] = metrics;
|
|
75
|
+
return metrics;
|
|
76
|
+
} catch {
|
|
77
|
+
metricCache[family] = null;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function readMetrics(_source) {
|
|
82
|
+
const source = typeof _source !== "string" && "href" in _source ? _source.href : _source;
|
|
83
|
+
if (source in metricCache) {
|
|
84
|
+
return Promise.resolve(metricCache[source]);
|
|
85
|
+
}
|
|
86
|
+
const { protocol } = parseURL(source);
|
|
87
|
+
if (!protocol)
|
|
88
|
+
return null;
|
|
89
|
+
const metrics = protocol === "file:" ? await fromFile(fileURLToPath(source)) : await fromUrl(source);
|
|
90
|
+
metricCache[source] = metrics;
|
|
91
|
+
return metrics;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const FontaineTransform = createUnplugin(
|
|
95
|
+
(options) => {
|
|
96
|
+
const cssContext = options.css = options.css || {};
|
|
97
|
+
cssContext.value = "";
|
|
98
|
+
return {
|
|
99
|
+
name: "nuxt-font-metrics-transform",
|
|
100
|
+
enforce: "pre",
|
|
101
|
+
transformInclude(id) {
|
|
102
|
+
const { pathname } = parseURL(id);
|
|
103
|
+
return pathname.endsWith(".css");
|
|
104
|
+
},
|
|
105
|
+
async transform(code, id) {
|
|
106
|
+
const s = new MagicString(code);
|
|
107
|
+
const faceRanges = [];
|
|
108
|
+
for (const match of code.matchAll(FONT_FACE_RE)) {
|
|
109
|
+
const matchContent = match[0];
|
|
110
|
+
if (match.index === void 0 || !matchContent)
|
|
111
|
+
continue;
|
|
112
|
+
faceRanges.push([match.index, match.index + matchContent.length]);
|
|
113
|
+
const { family, source } = parseFontFace(matchContent);
|
|
114
|
+
if (!family)
|
|
115
|
+
continue;
|
|
116
|
+
const metrics = await getMetricsForFamily(family) || (source ? await readMetrics(
|
|
117
|
+
options.resolvePath ? options.resolvePath(source) : source
|
|
118
|
+
).catch(() => null) : null);
|
|
119
|
+
if (metrics) {
|
|
120
|
+
const fontFace = generateFontFace(metrics, {
|
|
121
|
+
name: generateOverrideName(family),
|
|
122
|
+
fallbacks: options.fallbacks
|
|
123
|
+
});
|
|
124
|
+
cssContext.value += fontFace;
|
|
125
|
+
s.appendLeft(match.index, fontFace);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const match of code.matchAll(FONT_FAMILY_RE)) {
|
|
129
|
+
const { index, 0: matchContent } = match;
|
|
130
|
+
if (index === void 0 || !matchContent)
|
|
131
|
+
continue;
|
|
132
|
+
if (faceRanges.some(([start, end]) => index > start && index < end))
|
|
133
|
+
continue;
|
|
134
|
+
const families = matchContent.split(",").map((f) => f.trim());
|
|
135
|
+
s.overwrite(
|
|
136
|
+
index,
|
|
137
|
+
index + matchContent.length,
|
|
138
|
+
" " + [
|
|
139
|
+
families[0],
|
|
140
|
+
`"${generateOverrideName(families[0])}"`,
|
|
141
|
+
...families.slice(1)
|
|
142
|
+
].join(", ")
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (s.hasChanged()) {
|
|
146
|
+
return {
|
|
147
|
+
code: s.toString(),
|
|
148
|
+
map: options.sourcemap ? s.generateMap({ source: id, includeContent: true }) : void 0
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
const FONT_FACE_RE = createRegExp(
|
|
156
|
+
exactly("@font-face").and(whitespace.times.any()).and("{").and(charNotIn("}").times.any()).and("}"),
|
|
157
|
+
["g"]
|
|
158
|
+
);
|
|
159
|
+
const FONT_FAMILY_RE = createRegExp(
|
|
160
|
+
charNotIn(";}").times.any().as("family").after(exactly("font-family:").and(whitespace.times.any())).before(charIn(";}").or(exactly("").at.lineEnd())),
|
|
161
|
+
["g"]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
export { FontaineTransform };
|
package/package.json
CHANGED
|
@@ -1,14 +1,87 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fontaine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Automatic font fallback based on font metrics",
|
|
5
|
+
"repository": "danielroe/fontaine",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"fonts",
|
|
8
|
+
"cls",
|
|
9
|
+
"web-vitals",
|
|
10
|
+
"performance"
|
|
11
|
+
],
|
|
12
|
+
"author": {
|
|
13
|
+
"name": "Daniel Roe <daniel@roe.dev>",
|
|
14
|
+
"url": "https://github.com/danielroe"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"sideEffects": false,
|
|
4
18
|
"type": "module",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"import": "./dist/index.mjs",
|
|
22
|
+
"require": "./dist/index.cjs"
|
|
23
|
+
}
|
|
7
24
|
},
|
|
25
|
+
"main": "./dist/index.cjs",
|
|
26
|
+
"module": "./dist/index.mjs",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
8
28
|
"files": [
|
|
9
29
|
"dist"
|
|
10
30
|
],
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "unbuild",
|
|
33
|
+
"dev": "vitest",
|
|
34
|
+
"demo": "vite dev playground",
|
|
35
|
+
"demo:dev": "pnpm demo --config test/vite.config.mjs",
|
|
36
|
+
"lint": "pnpm lint:all:eslint && pnpm lint:all:prettier",
|
|
37
|
+
"lint:all:eslint": "pnpm lint:eslint --ext .ts,.js,.mjs,.cjs .",
|
|
38
|
+
"lint:all:prettier": "pnpm lint:prettier \"{src,test}/**/*.{js,json,ts}\"",
|
|
39
|
+
"lint:eslint": "eslint --fix",
|
|
40
|
+
"lint:prettier": "prettier --write --loglevel warn",
|
|
41
|
+
"prepare": "husky install && pnpm build",
|
|
42
|
+
"prepublishOnly": "pnpm lint && pnpm test && pinst --disable",
|
|
43
|
+
"release": "release-it",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"_postinstall": "husky install",
|
|
46
|
+
"postpublish": "pinst --enable"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@capsizecss/metrics": "^0.3.0",
|
|
50
|
+
"@capsizecss/unpack": "^0.1.0",
|
|
51
|
+
"magic-regexp": "^0.5.0",
|
|
52
|
+
"magic-string": "^0.26.4",
|
|
53
|
+
"pathe": "^0.3.8",
|
|
54
|
+
"scule": "^0.3.2",
|
|
55
|
+
"ufo": "^0.8.5",
|
|
56
|
+
"unplugin": "^0.9.6"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@nuxtjs/eslint-config-typescript": "latest",
|
|
60
|
+
"@release-it/conventional-changelog": "latest",
|
|
61
|
+
"@types/node": "latest",
|
|
62
|
+
"@types/serve-handler": "^6.1.1",
|
|
63
|
+
"@vitest/coverage-c8": "^0.23.4",
|
|
64
|
+
"c8": "latest",
|
|
65
|
+
"conventional-changelog-conventionalcommits": "latest",
|
|
66
|
+
"eslint": "latest",
|
|
67
|
+
"eslint-config-prettier": "latest",
|
|
68
|
+
"eslint-plugin-prettier": "latest",
|
|
69
|
+
"execa": "^6.1.0",
|
|
70
|
+
"expect-type": "latest",
|
|
71
|
+
"get-port-please": "^2.6.1",
|
|
72
|
+
"husky": "latest",
|
|
73
|
+
"lint-staged": "latest",
|
|
74
|
+
"pinst": "latest",
|
|
75
|
+
"prettier": "latest",
|
|
76
|
+
"release-it": "latest",
|
|
77
|
+
"serve-handler": "^6.1.3",
|
|
78
|
+
"typescript": "latest",
|
|
79
|
+
"unbuild": "latest",
|
|
80
|
+
"vite": "latest",
|
|
81
|
+
"vitest": "latest"
|
|
82
|
+
},
|
|
83
|
+
"resolutions": {
|
|
84
|
+
"fontaine": "link:."
|
|
85
|
+
},
|
|
86
|
+
"packageManager": "pnpm@7.5.1"
|
|
14
87
|
}
|