-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathvariable-parser.ts
More file actions
346 lines (295 loc) · 10.3 KB
/
variable-parser.ts
File metadata and controls
346 lines (295 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
import * as fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { Limiter } from '@evan/concurrency';
import { consola } from 'consola';
import stringify from 'json-stringify-pretty-compact';
import { dirname, join } from 'pathe';
import { compile } from 'stylis';
import { apiv2 as userAgents } from '../data/user-agents.json';
import { APIVariableDirect } from './data';
import { LOOP_LIMIT, addError, checkErrors } from './errors';
import type {
FontObjectVariable,
FontObjectVariableDirect,
FontVariantsVariable,
} from './types';
import { isAxesKey, isStandardAxesKey } from './types';
import { orderObject } from './utils';
import { validate } from './validate';
export type Links = Record<string, string>;
const queue = Limiter(10);
const results: FontObjectVariable = {};
// CSS API needs axes to given in alphabetical order or request throws e.g. (a,b,c,A,B,C)
export const sortAxes = (axesArr: string[]) => {
const upper = axesArr
.filter((axes) => axes === axes.toUpperCase())
.sort((a, b) => a.localeCompare(b));
const lower = axesArr
.filter((axes) => axes === axes.toLowerCase())
.sort((a, b) => a.localeCompare(b));
return [...lower, ...upper];
};
type MergedAxesTuple = [MergedAxes: string, MergedRange: string];
export const addAndMergeAxesRange = (
font: FontObjectVariableDirect,
axesArr: string[],
newAxes: string[],
): MergedAxesTuple => {
for (const axes of newAxes) {
if (!axesArr.includes(axes)) {
axesArr.push(axes);
}
}
const newAxesArr = sortAxes(axesArr);
const mergedAxes = newAxesArr.join(',');
// If ital, don't put in normal range and instead use toggle
const mergeRange = (mappedAxes: string) =>
mappedAxes === 'ital'
? '1'
: `${font.axes[mappedAxes].min}..${font.axes[mappedAxes].max}`;
const mergedRange = newAxesArr.map((axes) => mergeRange(axes)).join(',');
return [mergedAxes, mergedRange];
};
export const generateCSSLinks = (font: FontObjectVariableDirect): Links => {
const baseurl = 'https://fonts.googleapis.com/css2?family=';
const family = font.family.replaceAll(/\s/g, '+');
const links: Links = {};
let axesKeys = sortAxes(Object.keys(font.axes));
// ital can't be a range xx..xx and instead acts like a toggle e.g. 0 or 1
const hasItal = axesKeys.includes('ital');
// wght is technically supposed to be a mandatory axis... but extremely rarely it's not e.g. Ballet, Nabla
const hasWght = axesKeys.includes('wght');
// Remove wght and ital from axesKeys as we infer through hasItal and hasWght
axesKeys = axesKeys.filter((axis) => !['ital', 'wght'].includes(axis));
const isStandard = axesKeys.some((axis) => isStandardAxesKey(axis));
// Remove all standard axes and check for any non-standard keys
const isFull = axesKeys.some((axis) => !isStandardAxesKey(axis));
const fullAxes = [];
const standardAxes = [];
for (const axesKey of axesKeys) {
// We manually add support for new axes as they may have different rules to add to link
if (isAxesKey(axesKey)) {
const axes = font.axes[axesKey];
const range = `${axes.min}..${axes.max}`;
// Have full param arr instead of Object.keys() just in case there is unsupported axes
fullAxes.push(axesKey);
if (isStandardAxesKey(axesKey)) {
standardAxes.push(axesKey);
}
if (hasWght) {
const mergedTuple = addAndMergeAxesRange(font, [axesKey], ['wght']);
links[`${axesKey}.normal`] =
`${baseurl}${family}:${mergedTuple[0]}@${mergedTuple[1]}`;
if (hasItal) {
const italTuple = addAndMergeAxesRange(
font,
[axesKey],
['ital', 'wght'],
);
links[`${axesKey}.italic`] =
`${baseurl}${family}:${italTuple[0]}@${italTuple[1]}`;
}
} else {
links[`${axesKey}.normal`] = `${baseurl}${family}:${axesKey}@${range}`;
if (hasItal) {
const italTuple = addAndMergeAxesRange(font, [axesKey], ['ital']);
links[`${axesKey}.italic`] =
`${baseurl}${family}:${italTuple[0]}@${italTuple[1]}`;
}
}
} else {
consola.error(
`Unsupported axis: ${axesKey}\n Please make an issue on google-font-metadata to add support.`,
);
}
}
// Add just wght and ital variants
if (hasWght) {
let wghtTuple = addAndMergeAxesRange(font, ['wght'], []);
links['wght.normal'] =
`${baseurl}${family}:${wghtTuple[0]}@${wghtTuple[1]}`;
if (hasItal) {
wghtTuple = addAndMergeAxesRange(font, ['wght'], ['ital']);
links['wght.italic'] =
`${baseurl}${family}:${wghtTuple[0]}@${wghtTuple[1]}`;
}
}
// Full variant
if (isFull) {
let fullTuple = addAndMergeAxesRange(font, fullAxes, []);
if (hasWght) fullTuple = addAndMergeAxesRange(font, fullAxes, ['wght']);
links['full.normal'] =
`${baseurl}${family}:${fullTuple[0]}@${fullTuple[1]}`;
if (hasItal) {
let fullItalTuple = addAndMergeAxesRange(font, fullAxes, ['ital']);
if (hasWght)
fullItalTuple = addAndMergeAxesRange(font, fullAxes, ['ital', 'wght']);
links['full.italic'] =
`${baseurl}${family}:${fullItalTuple[0]}@${fullItalTuple[1]}`;
}
}
// Standard variant
if (isStandard) {
let standardTuple = addAndMergeAxesRange(font, standardAxes, []);
if (hasWght)
standardTuple = addAndMergeAxesRange(font, standardAxes, ['wght']);
links['standard.normal'] =
`${baseurl}${family}:${standardTuple[0]}@${standardTuple[1]}`;
if (hasItal) {
let standardItalTuple = addAndMergeAxesRange(font, standardAxes, [
'ital',
]);
if (hasWght)
standardItalTuple = addAndMergeAxesRange(font, standardAxes, [
'ital',
'wght',
]);
links['standard.italic'] =
`${baseurl}${family}:${standardItalTuple[0]}@${standardItalTuple[1]}`;
}
}
return links;
};
// Download CSS stylesheets using Google Fonts APIv2
export const fetchCSS = async (url: string) => {
const response = await fetch(url, {
headers: {
'User-Agent': userAgents.variable,
},
});
if (!response.ok) {
throw new Error(
`CSS fetch error (variable): Response code ${response.status} (${response.statusText})\nURL: ${url}`,
);
}
return response.text();
};
// [key, css]
export const fetchAllCSS = async (links: Links) =>
await (Promise.all(
Object.keys(links).map(async (key) => [key, await fetchCSS(links[key])]),
) as Promise<string[][]>); // Additional type assertion needed for pkgroll dts plugin
export const parseCSS = (cssTuple: string[][], defSubset?: string) => {
const fontVariants: FontVariantsVariable = {};
let subset = defSubset ?? 'latin';
for (const [key, cssVariant] of cssTuple) {
const [fontType, fontStyle] = key.split('.');
const rules = compile(cssVariant);
for (const rule of rules) {
if (rule.type === 'comm') {
if (typeof rule.children !== 'string')
throw new TypeError(
`Unknown child of comment: ${String(rule.children)}`,
);
subset = rule.children.trim();
// If subset is fallback, rename it to defSubset
if (defSubset !== undefined && subset === 'fallback')
subset = defSubset;
}
if (rule.type === '@font-face') {
// For each @font-face rule, we need to determine the actual subset
// This could come from a comment (old format) or URL pattern (new format)
let actualSubset = subset;
// First, look for any URL to extract subset from filename if needed.
for (const subrule of rule.children) {
if (typeof subrule !== 'string' && subrule.props === 'src') {
if (typeof subrule.children === 'string') {
const typeMatch = /(url)\((.+?)\)/g;
const match: string[][] = [
...subrule.children.matchAll(typeMatch),
];
if (match.length > 0) {
const path: string = match[0][2];
actualSubset = extractSubsetFromUrl(
path,
subset,
defSubset ?? 'latin',
);
break;
}
}
}
}
// Then process all properties with the correct subset
for (const subrule of rule.children) {
// Type guard to ensure there are children in font-face rules
if (typeof subrule === 'string')
throw new TypeError(`Unknown subrule: ${subrule}`);
// Build out nested objects
fontVariants[fontType] = fontVariants[fontType] || {};
fontVariants[fontType][fontStyle] =
fontVariants[fontType][fontStyle] || {};
// Define src props
if (subrule.props === 'src') {
if (typeof subrule.children !== 'string')
throw new TypeError(
`Unknown src child: ${String(subrule.children)}`,
);
const typeMatch = /(url)\((.+?)\)/g;
// Finds all groups that match the regex using the string.matchAll function
const match: string[][] = [...subrule.children.matchAll(typeMatch)];
const type: string = match[0][1];
const path: string = match[0][2];
if (type === 'url')
fontVariants[fontType][fontStyle][actualSubset] = path;
}
}
}
}
}
return fontVariants;
};
const processQueue = async (font: FontObjectVariableDirect) => {
try {
const cssLinks = generateCSSLinks(font);
const cssTuple = await fetchAllCSS(cssLinks);
const variantsObject = parseCSS(cssTuple);
results[font.id] = { ...font, variants: variantsObject };
consola.success(`Parsed ${font.id}`);
} catch (error) {
addError(`${font.family} experienced an error. ${String(error)}`);
}
};
/**
* Parses the scraped variable font data into a usable APIVariable dataset,
* @param noValidate - Skip automatic validation of parsed dataset.
*/
export const parseVariable = async (noValidate: boolean) => {
for (const font of APIVariableDirect) {
checkErrors(LOOP_LIMIT);
queue.add(() => processQueue(font));
}
await queue.flush();
checkErrors();
if (!noValidate) {
validate('variable', results);
}
const ordered = orderObject(results);
await fs.writeFile(
join(dirname(fileURLToPath(import.meta.url)), '../data/variable.json'),
stringify(ordered),
);
consola.success(
`All ${
Object.keys(results).length
} variable font datapoints have been generated.`,
);
};
/**
* Extract subset from URL filename for numbered subsets.
* Falls back to current subset if no numbered pattern is found.
*/
const extractSubsetFromUrl = (
url: string,
currentSubset: string,
defSubset: string,
): string => {
// If current subset is not the default, it was set by a comment, so use it
if (currentSubset !== defSubset) return currentSubset;
// Extract numbered subset from filename pattern like .123.woff2
const match = url.match(/\.(\d+)\.(woff2?|ttf|otf)$/);
if (match) {
return `[${match[1]}]`;
}
return currentSubset;
};