@liiift-studio/sanity-font-manager 2.3.18 → 2.4.0

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.
Files changed (42) hide show
  1. package/README.md +437 -437
  2. package/dist/index.js +103 -48
  3. package/dist/index.mjs +103 -48
  4. package/package.json +85 -85
  5. package/src/components/BatchUploadFonts.jsx +640 -639
  6. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  7. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  8. package/src/components/KeyValueInput.jsx +95 -95
  9. package/src/components/KeyValueReferenceInput.jsx +254 -254
  10. package/src/components/NestedObjectArraySelector.jsx +146 -146
  11. package/src/components/PriceInput.jsx +26 -26
  12. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  13. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  14. package/src/components/SetOTF.jsx +87 -87
  15. package/src/components/SingleUploaderTool.jsx +673 -673
  16. package/src/components/StatusDisplay.jsx +26 -26
  17. package/src/components/StyleCountInput.jsx +16 -16
  18. package/src/components/UpdateScriptsComponent.jsx +76 -76
  19. package/src/components/UploadButton.jsx +43 -43
  20. package/src/components/UploadScriptsComponent.jsx +537 -537
  21. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  22. package/src/hooks/useNestedObjects.js +92 -92
  23. package/src/hooks/useSanityClient.js +9 -9
  24. package/src/index.js +70 -70
  25. package/src/schema/openTypeField.js +1945 -1945
  26. package/src/schema/styleCountField.js +12 -12
  27. package/src/schema/stylesField.js +268 -268
  28. package/src/schema/stylisticSetField.js +301 -301
  29. package/src/utils/generateCssFile.js +205 -205
  30. package/src/utils/generateFontData.js +145 -145
  31. package/src/utils/generateFontFile.js +38 -38
  32. package/src/utils/generateKeywords.js +185 -185
  33. package/src/utils/generateSubset.js +45 -45
  34. package/src/utils/getEmptyFontKit.js +99 -99
  35. package/src/utils/parseVariableFontInstances.js +211 -211
  36. package/src/utils/processFontFiles.js +487 -477
  37. package/src/utils/regenerateFontData.js +146 -146
  38. package/src/utils/sanitizeForSanityId.js +65 -65
  39. package/src/utils/updateFontPrices.js +94 -94
  40. package/src/utils/updateTypefaceDocument.js +149 -160
  41. package/src/utils/uploadFontFiles.js +115 -26
  42. package/src/utils/utils.js +24 -24
@@ -1,477 +1,487 @@
1
- // Reads font files via FileReader, parses with fontkit, and builds the fontsObjects map — exports individual weight/style extraction helpers
2
-
3
- import * as fontkit from 'fontkit';
4
- import { nanoid } from 'nanoid';
5
- import { expandAbbreviations, removeWeightNames, reverseSpellingLookup } from './generateKeywords';
6
- import { sanitizeForSanityId } from './sanitizeForSanityId';
7
-
8
- /**
9
- * Reads a font file and returns its content as a Uint8Array.
10
- * @param {File} file
11
- * @returns {Promise<Uint8Array>}
12
- */
13
- export const readFontFile = (file) => {
14
- return new Promise((resolve, reject) => {
15
- const reader = new FileReader();
16
- reader.onload = (event) => { resolve(new Uint8Array(event.target.result)); };
17
- reader.onerror = (error) => { reject(error); };
18
- reader.readAsArrayBuffer(file);
19
- });
20
- };
21
-
22
- /**
23
- * Processes an array of font files and extracts metadata for each.
24
- * @param {File[]} files
25
- * @param {string} title - Typeface title
26
- * @param {string[]} weightKeywordList
27
- * @param {string[]} italicKeywordList
28
- * @param {Function} setStatus
29
- * @param {boolean} preserveShortenedNames - Skip abbreviation expansion when true
30
- * @param {boolean} preserveFileNames - Preserve original filename capitalization when true
31
- * @returns {Promise<Object>}
32
- */
33
- export const processFontFiles = async (
34
- files,
35
- title,
36
- weightKeywordList,
37
- italicKeywordList,
38
- setStatus,
39
- preserveShortenedNames = false,
40
- preserveFileNames = false,
41
- ) => {
42
- let failedFiles = [];
43
- let subfamilies = {};
44
- let fontsObjects = {};
45
- let newPreferredStyle = { weight: -100, style: 'Italic', _ref: '' };
46
-
47
- for (let i = 0; i < files.length; i++) {
48
- const file = files[i];
49
- const fontBuffer = await readFontFile(file);
50
- const font = fontkit.create(fontBuffer);
51
-
52
- console.log('File name: ', file.name);
53
-
54
- if (file.name.endsWith('.woff2') || file.name.endsWith('.woff')) {
55
- await handleWebfontMetadata(file, font, files);
56
- }
57
-
58
- const { weightName, subfamilyName, fontTitle, style, italicKW, variableFont } = extractFontMetadata(
59
- font,
60
- title,
61
- weightKeywordList,
62
- italicKeywordList,
63
- preserveShortenedNames,
64
- );
65
-
66
- const id = sanitizeForSanityId(fontTitle);
67
-
68
- let originalFilename = null;
69
- if (preserveFileNames) {
70
- originalFilename = file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, '');
71
- }
72
-
73
- logFontInfo(id, fontTitle, font, file.name, subfamilyName, style, weightName, variableFont, italicKW);
74
-
75
- subfamilies[id] = subfamilyName;
76
-
77
- if (fontsObjects[id]) {
78
- fontsObjects[id].files = [...fontsObjects[id].files, file];
79
- if (preserveFileNames && originalFilename) {
80
- fontsObjects[id].originalFilename = originalFilename;
81
- }
82
- } else {
83
- fontsObjects[id] = createFontObject(
84
- id,
85
- fontTitle,
86
- title,
87
- font,
88
- variableFont,
89
- weightName,
90
- subfamilyName,
91
- file,
92
- preserveFileNames ? originalFilename : null,
93
- );
94
- }
95
- }
96
-
97
- fontsObjects = sortFontObjects(fontsObjects);
98
- const uniqueSubfamilies = [...new Set(Object.values(subfamilies))];
99
-
100
- console.log('Subfamilies:', subfamilies);
101
- console.log('Unique subfamilies:', uniqueSubfamilies, uniqueSubfamilies.length);
102
- console.log('Font objects:', fontsObjects);
103
-
104
- return { fontsObjects, subfamilies, uniqueSubfamilies, newPreferredStyle, failedFiles };
105
- };
106
-
107
- /**
108
- * Patches webfont name records from a matching TTF when woff/woff2 metadata is missing.
109
- * @param {File} file
110
- * @param {Object} font
111
- * @param {File[]} files
112
- */
113
- const handleWebfontMetadata = async (file, font, files) => {
114
- if (
115
- !font?.name?.records?.fullName ||
116
- font?.name?.records?.fullName === '' ||
117
- !/^[A-Z0-9]+$/.test(font?.name?.records?.fullName)
118
- ) {
119
- const ttfFile = files.find(f => f.name === file.name.replace('.woff2', '.ttf').replace('.woff', '.ttf'));
120
- if (ttfFile) {
121
- const ttfFileBuffer = await readFontFile(ttfFile);
122
- const ttfFileData = fontkit.create(ttfFileBuffer);
123
- if (ttfFileData) font.name.records = ttfFileData?.name?.records;
124
- }
125
- }
126
- };
127
-
128
- /**
129
- * Extracts and normalises metadata from a fontkit font object.
130
- * @param {Object} font
131
- * @param {string} title
132
- * @param {string[]} weightKeywordList
133
- * @param {string[]} italicKeywordList
134
- * @param {boolean} preserveShortenedNames
135
- * @returns {Object}
136
- */
137
- export const extractFontMetadata = (font, title, weightKeywordList, italicKeywordList, preserveShortenedNames = false) => {
138
- let weightName = extractWeightName(font, italicKeywordList);
139
- if (!preserveShortenedNames) {
140
- weightName = expandAbbreviations(weightName);
141
- }
142
-
143
- if ((weightName === '' || weightName.toLowerCase() === 'roman') && font?.name?.records?.fullName) {
144
- weightName = extractWeightFromFullName(font, title);
145
- if (!preserveShortenedNames) {
146
- weightName = expandAbbreviations(weightName);
147
- }
148
- }
149
-
150
- const variableFont = font?.variationAxes && Object.keys(font.variationAxes).length > 0;
151
-
152
- let subfamilyName = font?.name?.records?.fullName?.en?.replace(title.trim(), '').trim() ||
153
- font.subfamilyName.trim().replace(title.trim(), '').trim();
154
-
155
- if (!preserveShortenedNames) {
156
- subfamilyName = expandAbbreviations(subfamilyName);
157
- }
158
-
159
- let fontTitle = font?.fullName.trim();
160
- let style = (font?.italicAngle !== 0 || font?.fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular';
161
-
162
- const italicKW = processItalicKeywords(font, fontTitle, italicKeywordList);
163
-
164
- subfamilyName = processSubfamilyName(subfamilyName, weightKeywordList, italicKW, preserveShortenedNames);
165
- fontTitle = formatFontTitle(fontTitle, preserveShortenedNames);
166
-
167
- subfamilyName = subfamilyName === '' ? 'Regular' : subfamilyName.replace(/\s+/g, ' ').trim();
168
-
169
- if (subfamilyName !== '') {
170
- weightName = weightName
171
- .replace(`${subfamilyName} `, '')
172
- .replace(` ${subfamilyName}`, '')
173
- .trim();
174
- }
175
-
176
- if (variableFont) {
177
- if (!fontTitle.toLowerCase().includes('vf')) {
178
- fontTitle = fontTitle + ' VF';
179
- }
180
- subfamilyName = '';
181
- }
182
-
183
- if (!(variableFont && fontTitle.toLowerCase().includes('italic'))) {
184
- fontTitle = addItalicToFontTitle(font, fontTitle, italicKW, style, preserveShortenedNames);
185
- }
186
-
187
- return { weightName, subfamilyName, fontTitle, style, italicKW, variableFont };
188
- };
189
-
190
- /**
191
- * Extracts the weight name from a font's preferred subfamily or subfamily record.
192
- * Returns "Variable" for variable fonts.
193
- * @param {Object} font
194
- * @param {string[]} italicKW
195
- * @returns {string}
196
- */
197
- export const extractWeightName = (font, italicKW) => {
198
- let weightName = font?.name?.records?.preferredSubfamily || font?.name?.records?.fontSubfamily;
199
-
200
- if (typeof weightName === 'object') {
201
- weightName = weightName?.en ||
202
- (weightName.constructor === Object ? weightName[Object.keys(weightName)[0]] : null);
203
- }
204
-
205
- if (font?.variationAxes && Object.keys(font.variationAxes).length > 0) {
206
- return 'Variable';
207
- }
208
-
209
- if (italicKW) {
210
- italicKW.forEach(keyword => {
211
- const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
212
- if (kwRegex.test(weightName)) {
213
- weightName = weightName.replace(kwRegex, '').trim();
214
- }
215
- });
216
- }
217
-
218
- return weightName?.toString()
219
- .replace('Italic', '')
220
- .replace('It', '')
221
- .replace('Slanted', '')
222
- .replace('Slant', '')
223
- .replace('Backslant', '')
224
- .trim();
225
- };
226
-
227
- /**
228
- * Extracts a weight name from the font's full name record when subfamily is empty or "Roman".
229
- * @param {Object} font
230
- * @param {string} title
231
- * @returns {string}
232
- */
233
- export const extractWeightFromFullName = (font, title) => {
234
- let weightName = font?.name?.records?.fullName;
235
- weightName = weightName?.en
236
- ? weightName.en
237
- : (weightName?.constructor === Object ? weightName[Object.keys(weightName)[0]] : weightName);
238
- weightName = weightName?.replace(title + ' ', '').replace(title, '').trim();
239
- weightName = weightName?.replace('Italic', '').replace('It', '').replace('Slanted', '').replace('Slant', '').trim();
240
- return weightName;
241
- };
242
-
243
- /**
244
- * Strips weight and italic keywords from a subfamily name string.
245
- * @param {string} subfamilyName
246
- * @param {string[]} weightKeywordList
247
- * @param {string[]} italicKeywordList
248
- * @param {boolean} preserveShortenedNames
249
- * @returns {string}
250
- */
251
- export const processSubfamilyName = (subfamilyName, weightKeywordList, italicKeywordList, preserveShortenedNames = false) => {
252
- weightKeywordList.forEach(keyword => {
253
- const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
254
- if (kwRegex.test(subfamilyName)) {
255
- subfamilyName = subfamilyName.replace(kwRegex, '').trim();
256
- }
257
- subfamilyName = removeWeightNames(subfamilyName) || subfamilyName;
258
- if (!preserveShortenedNames) {
259
- subfamilyName = expandAbbreviations(subfamilyName);
260
- }
261
- });
262
-
263
- italicKeywordList.forEach(keyword => {
264
- const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
265
- if (kwRegex.test(subfamilyName)) {
266
- subfamilyName = subfamilyName.replace(kwRegex, '').trim();
267
- }
268
- });
269
-
270
- return subfamilyName;
271
- };
272
-
273
- /**
274
- * Collects italic keywords present in a font's full name.
275
- * @param {Object} font
276
- * @param {string} fontTitle
277
- * @param {string[]} italicKeywordList
278
- * @returns {string[]}
279
- */
280
- export const processItalicKeywords = (font, fontTitle, italicKeywordList) => {
281
- let italicKW = [];
282
-
283
- italicKeywordList.forEach(keyword => {
284
- const kw = keyword.trim();
285
- const kwRegex = new RegExp(`\\b${kw}\\b`, 'i');
286
- if (kwRegex.test(fontTitle)) {
287
- fontTitle = fontTitle.replace(kwRegex, '').trim();
288
- italicKW.push(kw);
289
- }
290
- if (font?.fullName && typeof font.fullName === 'string' && font.fullName.toLowerCase().includes(kw.toLowerCase())) {
291
- if (!italicKW.includes(kw)) italicKW.push(kw);
292
- }
293
- });
294
-
295
- return italicKW;
296
- };
297
-
298
- /**
299
- * Normalises and title-cases a font title, optionally expanding abbreviations.
300
- * @param {string} fontTitle
301
- * @param {boolean} preserveShortenedNames
302
- * @returns {string}
303
- */
304
- export const formatFontTitle = (fontTitle, preserveShortenedNames = false) => {
305
- const hasItalic = fontTitle.toLowerCase().includes('italic');
306
- fontTitle = fontTitle.replace(/-/g, ' ');
307
-
308
- return fontTitle.replace(/\s+/g, ' ').trim().split(' ').map(word => {
309
- if (hasItalic && word.toLowerCase() === 'italic') return 'Italic';
310
- let fullWord = word;
311
- if (!preserveShortenedNames) {
312
- fullWord = reverseSpellingLookup(word) || word;
313
- }
314
- return fullWord[0].toUpperCase() + fullWord.slice(1);
315
- }).join(' ');
316
- };
317
-
318
- /**
319
- * Appends any italic keywords to the font title that aren't already present.
320
- * @param {Object} font
321
- * @param {string} fontTitle
322
- * @param {string[]} italicKW
323
- * @param {string} style
324
- * @param {boolean} preserveShortenedNames
325
- * @returns {string}
326
- */
327
- export const addItalicToFontTitle = (font, fontTitle, italicKW, style, preserveShortenedNames = false) => {
328
- const hasItalicAngle = font?.italicAngle !== 0;
329
- const hasItalicInName = font?.fullName.toLowerCase().includes('italic');
330
-
331
- if (italicKW.length > 0 || hasItalicAngle || hasItalicInName) {
332
- italicKW = [...new Set(italicKW)];
333
-
334
- if (italicKW.length === 0 && (hasItalicAngle || hasItalicInName)) {
335
- italicKW = ['Italic'];
336
- }
337
-
338
- if (!preserveShortenedNames) {
339
- italicKW = italicKW.map(item => reverseSpellingLookup(item) || item);
340
- }
341
-
342
- italicKW = [...new Set(italicKW)];
343
-
344
- if (italicKW.length > 1 && italicKW.includes('Italic')) {
345
- italicKW = ['Italic'];
346
- }
347
-
348
- const fontTitleLower = fontTitle.toLowerCase();
349
- italicKW = italicKW.filter(keyword => {
350
- const keywordLower = keyword.toLowerCase();
351
- const kwRegex = new RegExp(`\\b${keywordLower}\\b`);
352
- const isSubstring = fontTitleLower.split(' ').some(word =>
353
- word.includes(keywordLower) || keywordLower.includes(word)
354
- );
355
- return !kwRegex.test(fontTitleLower) && !isSubstring;
356
- });
357
-
358
- if (italicKW.length > 0) {
359
- fontTitle = fontTitle.trim() + ' ' + italicKW.join(' ');
360
- }
361
- }
362
-
363
- return fontTitle;
364
- };
365
-
366
- /**
367
- * Builds a font object ready for staging and upload.
368
- * originalFilename is stored temporarily and deleted before saving to Sanity.
369
- * @param {string} id
370
- * @param {string} fontTitle
371
- * @param {string} title
372
- * @param {Object} font
373
- * @param {boolean} variableFont
374
- * @param {string} weightName
375
- * @param {string} subfamilyName
376
- * @param {File} file
377
- * @param {string|null} originalFilename
378
- * @returns {Object}
379
- */
380
- export const createFontObject = (id, fontTitle, title, font, variableFont, weightName, subfamilyName, file, originalFilename = null) => {
381
- const fontObject = {
382
- _key: nanoid(),
383
- _id: id,
384
- title: fontTitle,
385
- slug: { _type: 'slug', current: id },
386
- typefaceName: title,
387
- style: (font?.italicAngle !== 0 || font?.fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular',
388
- variableFont: variableFont,
389
- weightName: weightName,
390
- subfamily: subfamilyName,
391
- normalWeight: true,
392
- weight: Number(determineWeight(font, weightName)),
393
- fileInput: {},
394
- files: [file],
395
- fontKit: font,
396
- };
397
-
398
- if (originalFilename) {
399
- fontObject.originalFilename = originalFilename;
400
- }
401
-
402
- return fontObject;
403
- };
404
-
405
- /**
406
- * Determines a numeric CSS weight value for a font.
407
- * @param {Object} font
408
- * @param {string} weightName
409
- * @returns {number}
410
- */
411
- export const determineWeight = (font, weightName) => {
412
- if (font['OS/2']?.usWeightClass) {
413
- return Number(font['OS/2'].usWeightClass);
414
- }
415
-
416
- const wn = weightName?.toLowerCase() || '';
417
-
418
- if (/hairline|extra thin|extrathin/.test(wn)) return 100;
419
- if (/thin|extra light|extralight/.test(wn)) return 200;
420
- if (/light|book/.test(wn)) return 300;
421
- if (/regular|normal/.test(wn)) return 400;
422
- if (/medium/.test(wn)) return 500;
423
- if (/semi bold|semibold/.test(wn)) return 600;
424
- if (/extra bold|extrabold/.test(wn)) return 800;
425
- if (/bold/.test(wn)) return 700;
426
- if (/black|ultra/.test(wn)) return 900;
427
-
428
- return 400;
429
- };
430
-
431
- /**
432
- * Sorts a map of font objects by ascending weight, with Regular before Italic at equal weights.
433
- * @param {Object} fontsObjects
434
- * @returns {Object}
435
- */
436
- export const sortFontObjects = (fontsObjects) => {
437
- return Object.fromEntries(
438
- Object.entries(fontsObjects).sort((a, b) => {
439
- const weightA = Number(a[1].weight);
440
- const weightB = Number(b[1].weight);
441
- if (weightA === weightB) {
442
- if (a[1].style === 'Regular' && b[1].style === 'Italic') return -1;
443
- if (a[1].style === 'Italic' && b[1].style === 'Regular') return 1;
444
- return 0;
445
- }
446
- return weightA - weightB;
447
- })
448
- );
449
- };
450
-
451
- /**
452
- * Logs font metadata to the console for debugging.
453
- * @param {string} id
454
- * @param {string} fontTitle
455
- * @param {Object} font
456
- * @param {string} fileName
457
- * @param {string} subfamilyName
458
- * @param {string} style
459
- * @param {string} weightName
460
- * @param {boolean} variableFont
461
- * @param {string[]} italicKW
462
- */
463
- export const logFontInfo = (id, fontTitle, font, fileName, subfamilyName, style, weightName, variableFont, italicKW) => {
464
- console.log('=== Font Info ====');
465
- console.log('Font id: ', id);
466
- console.log('Font title: ', fontTitle);
467
- console.log('Fontkit fullName: ', font.fullName);
468
- console.log('Fontkit family name: ', font.familyName);
469
- console.log('File name: ', fileName);
470
- console.log('Subfamily: ', subfamilyName);
471
- console.log('Style: ', style);
472
- console.log('Weight: ', weightName);
473
- console.log('Variable: ', variableFont);
474
- console.log('italicKW: ', italicKW);
475
- console.log('Font italic angle: ', (font?.italicAngle !== 0 || font?.fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular');
476
- console.log('=======');
477
- };
1
+ // Reads font files via FileReader, parses with fontkit, and builds the fontsObjects map — exports individual weight/style extraction helpers
2
+
3
+ import * as fontkit from 'fontkit';
4
+ import { nanoid } from 'nanoid';
5
+ import { expandAbbreviations, removeWeightNames, reverseSpellingLookup } from './generateKeywords';
6
+ import { sanitizeForSanityId } from './sanitizeForSanityId';
7
+
8
+ /**
9
+ * Reads a font file and returns its content as a Uint8Array.
10
+ * @param {File} file
11
+ * @returns {Promise<Uint8Array>}
12
+ */
13
+ export const readFontFile = (file) => {
14
+ return new Promise((resolve, reject) => {
15
+ const reader = new FileReader();
16
+ reader.onload = (event) => { resolve(new Uint8Array(event.target.result)); };
17
+ reader.onerror = (error) => { reject(error); };
18
+ reader.readAsArrayBuffer(file);
19
+ });
20
+ };
21
+
22
+ /**
23
+ * Processes an array of font files and extracts metadata for each.
24
+ * @param {File[]} files
25
+ * @param {string} title - Typeface title
26
+ * @param {string[]} weightKeywordList
27
+ * @param {string[]} italicKeywordList
28
+ * @param {Function} setStatus
29
+ * @param {boolean} preserveShortenedNames - Skip abbreviation expansion when true
30
+ * @param {boolean} preserveFileNames - Preserve original filename capitalization when true
31
+ * @returns {Promise<Object>}
32
+ */
33
+ export const processFontFiles = async (
34
+ files,
35
+ title,
36
+ weightKeywordList,
37
+ italicKeywordList,
38
+ setStatus,
39
+ preserveShortenedNames = false,
40
+ preserveFileNames = false,
41
+ ) => {
42
+ let failedFiles = [];
43
+ let subfamilies = {};
44
+ let fontsObjects = {};
45
+ let newPreferredStyle = { weight: -100, style: 'Italic', _ref: '' };
46
+
47
+ for (let i = 0; i < files.length; i++) {
48
+ const file = files[i];
49
+ const fontBuffer = await readFontFile(file);
50
+ const font = fontkit.create(fontBuffer);
51
+
52
+ console.log('File name: ', file.name);
53
+
54
+ if (file.name.endsWith('.woff2') || file.name.endsWith('.woff')) {
55
+ await handleWebfontMetadata(file, font, files);
56
+ }
57
+
58
+ let { weightName, subfamilyName, fontTitle, style, italicKW, variableFont } = extractFontMetadata(
59
+ font,
60
+ title,
61
+ weightKeywordList,
62
+ italicKeywordList,
63
+ preserveShortenedNames,
64
+ );
65
+
66
+ let id;
67
+ let originalFilename = null;
68
+
69
+ if (preserveFileNames) {
70
+ originalFilename = file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, '');
71
+ // Normalize filename: hyphens to spaces, split camelCase boundaries, collapse whitespace
72
+ const normalizedName = originalFilename
73
+ .replace(/-/g, ' ')
74
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
75
+ .replace(/\s+/g, ' ')
76
+ .trim();
77
+ fontTitle = normalizedName;
78
+ id = sanitizeForSanityId(normalizedName);
79
+ } else {
80
+ id = sanitizeForSanityId(fontTitle);
81
+ }
82
+
83
+ logFontInfo(id, fontTitle, font, file.name, subfamilyName, style, weightName, variableFont, italicKW);
84
+
85
+ subfamilies[id] = subfamilyName;
86
+
87
+ if (fontsObjects[id]) {
88
+ fontsObjects[id].files = [...fontsObjects[id].files, file];
89
+ if (preserveFileNames && originalFilename) {
90
+ fontsObjects[id].originalFilename = originalFilename;
91
+ }
92
+ } else {
93
+ fontsObjects[id] = createFontObject(
94
+ id,
95
+ fontTitle,
96
+ title,
97
+ font,
98
+ variableFont,
99
+ weightName,
100
+ subfamilyName,
101
+ file,
102
+ preserveFileNames ? originalFilename : null,
103
+ );
104
+ }
105
+ }
106
+
107
+ fontsObjects = sortFontObjects(fontsObjects);
108
+ const uniqueSubfamilies = [...new Set(Object.values(subfamilies))];
109
+
110
+ console.log('Subfamilies:', subfamilies);
111
+ console.log('Unique subfamilies:', uniqueSubfamilies, uniqueSubfamilies.length);
112
+ console.log('Font objects:', fontsObjects);
113
+
114
+ return { fontsObjects, subfamilies, uniqueSubfamilies, newPreferredStyle, failedFiles };
115
+ };
116
+
117
+ /**
118
+ * Patches webfont name records from a matching TTF when woff/woff2 metadata is missing.
119
+ * @param {File} file
120
+ * @param {Object} font
121
+ * @param {File[]} files
122
+ */
123
+ const handleWebfontMetadata = async (file, font, files) => {
124
+ if (
125
+ !font?.name?.records?.fullName ||
126
+ font?.name?.records?.fullName === '' ||
127
+ !/^[A-Z0-9]+$/.test(font?.name?.records?.fullName)
128
+ ) {
129
+ const ttfFile = files.find(f => f.name === file.name.replace('.woff2', '.ttf').replace('.woff', '.ttf'));
130
+ if (ttfFile) {
131
+ const ttfFileBuffer = await readFontFile(ttfFile);
132
+ const ttfFileData = fontkit.create(ttfFileBuffer);
133
+ if (ttfFileData) font.name.records = ttfFileData?.name?.records;
134
+ }
135
+ }
136
+ };
137
+
138
+ /**
139
+ * Extracts and normalises metadata from a fontkit font object.
140
+ * @param {Object} font
141
+ * @param {string} title
142
+ * @param {string[]} weightKeywordList
143
+ * @param {string[]} italicKeywordList
144
+ * @param {boolean} preserveShortenedNames
145
+ * @returns {Object}
146
+ */
147
+ export const extractFontMetadata = (font, title, weightKeywordList, italicKeywordList, preserveShortenedNames = false) => {
148
+ let weightName = extractWeightName(font, italicKeywordList);
149
+ if (!preserveShortenedNames) {
150
+ weightName = expandAbbreviations(weightName);
151
+ }
152
+
153
+ if ((weightName === '' || weightName.toLowerCase() === 'roman') && font?.name?.records?.fullName) {
154
+ weightName = extractWeightFromFullName(font, title);
155
+ if (!preserveShortenedNames) {
156
+ weightName = expandAbbreviations(weightName);
157
+ }
158
+ }
159
+
160
+ const variableFont = font?.variationAxes && Object.keys(font.variationAxes).length > 0;
161
+
162
+ let subfamilyName = font?.name?.records?.fullName?.en?.replace(title.trim(), '').trim() ||
163
+ font.subfamilyName.trim().replace(title.trim(), '').trim();
164
+
165
+ if (!preserveShortenedNames) {
166
+ subfamilyName = expandAbbreviations(subfamilyName);
167
+ }
168
+
169
+ let fontTitle = font?.fullName.trim();
170
+ let style = (font?.italicAngle !== 0 || font?.fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular';
171
+
172
+ const italicKW = processItalicKeywords(font, fontTitle, italicKeywordList);
173
+
174
+ subfamilyName = processSubfamilyName(subfamilyName, weightKeywordList, italicKW, preserveShortenedNames);
175
+ fontTitle = formatFontTitle(fontTitle, preserveShortenedNames);
176
+
177
+ subfamilyName = subfamilyName === '' ? 'Regular' : subfamilyName.replace(/\s+/g, ' ').trim();
178
+
179
+ if (subfamilyName !== '') {
180
+ weightName = weightName
181
+ .replace(`${subfamilyName} `, '')
182
+ .replace(` ${subfamilyName}`, '')
183
+ .trim();
184
+ }
185
+
186
+ if (variableFont) {
187
+ if (!fontTitle.toLowerCase().includes('vf')) {
188
+ fontTitle = fontTitle + ' VF';
189
+ }
190
+ subfamilyName = '';
191
+ }
192
+
193
+ if (!(variableFont && fontTitle.toLowerCase().includes('italic'))) {
194
+ fontTitle = addItalicToFontTitle(font, fontTitle, italicKW, style, preserveShortenedNames);
195
+ }
196
+
197
+ return { weightName, subfamilyName, fontTitle, style, italicKW, variableFont };
198
+ };
199
+
200
+ /**
201
+ * Extracts the weight name from a font's preferred subfamily or subfamily record.
202
+ * Returns "Variable" for variable fonts.
203
+ * @param {Object} font
204
+ * @param {string[]} italicKW
205
+ * @returns {string}
206
+ */
207
+ export const extractWeightName = (font, italicKW) => {
208
+ let weightName = font?.name?.records?.preferredSubfamily || font?.name?.records?.fontSubfamily;
209
+
210
+ if (typeof weightName === 'object') {
211
+ weightName = weightName?.en ||
212
+ (weightName.constructor === Object ? weightName[Object.keys(weightName)[0]] : null);
213
+ }
214
+
215
+ if (font?.variationAxes && Object.keys(font.variationAxes).length > 0) {
216
+ return 'Variable';
217
+ }
218
+
219
+ if (italicKW) {
220
+ italicKW.forEach(keyword => {
221
+ const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
222
+ if (kwRegex.test(weightName)) {
223
+ weightName = weightName.replace(kwRegex, '').trim();
224
+ }
225
+ });
226
+ }
227
+
228
+ return weightName?.toString()
229
+ .replace('Italic', '')
230
+ .replace('It', '')
231
+ .replace('Slanted', '')
232
+ .replace('Slant', '')
233
+ .replace('Backslant', '')
234
+ .trim();
235
+ };
236
+
237
+ /**
238
+ * Extracts a weight name from the font's full name record when subfamily is empty or "Roman".
239
+ * @param {Object} font
240
+ * @param {string} title
241
+ * @returns {string}
242
+ */
243
+ export const extractWeightFromFullName = (font, title) => {
244
+ let weightName = font?.name?.records?.fullName;
245
+ weightName = weightName?.en
246
+ ? weightName.en
247
+ : (weightName?.constructor === Object ? weightName[Object.keys(weightName)[0]] : weightName);
248
+ weightName = weightName?.replace(title + ' ', '').replace(title, '').trim();
249
+ weightName = weightName?.replace('Italic', '').replace('It', '').replace('Slanted', '').replace('Slant', '').trim();
250
+ return weightName;
251
+ };
252
+
253
+ /**
254
+ * Strips weight and italic keywords from a subfamily name string.
255
+ * @param {string} subfamilyName
256
+ * @param {string[]} weightKeywordList
257
+ * @param {string[]} italicKeywordList
258
+ * @param {boolean} preserveShortenedNames
259
+ * @returns {string}
260
+ */
261
+ export const processSubfamilyName = (subfamilyName, weightKeywordList, italicKeywordList, preserveShortenedNames = false) => {
262
+ weightKeywordList.forEach(keyword => {
263
+ const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
264
+ if (kwRegex.test(subfamilyName)) {
265
+ subfamilyName = subfamilyName.replace(kwRegex, '').trim();
266
+ }
267
+ subfamilyName = removeWeightNames(subfamilyName) || subfamilyName;
268
+ if (!preserveShortenedNames) {
269
+ subfamilyName = expandAbbreviations(subfamilyName);
270
+ }
271
+ });
272
+
273
+ italicKeywordList.forEach(keyword => {
274
+ const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
275
+ if (kwRegex.test(subfamilyName)) {
276
+ subfamilyName = subfamilyName.replace(kwRegex, '').trim();
277
+ }
278
+ });
279
+
280
+ return subfamilyName;
281
+ };
282
+
283
+ /**
284
+ * Collects italic keywords present in a font's full name.
285
+ * @param {Object} font
286
+ * @param {string} fontTitle
287
+ * @param {string[]} italicKeywordList
288
+ * @returns {string[]}
289
+ */
290
+ export const processItalicKeywords = (font, fontTitle, italicKeywordList) => {
291
+ let italicKW = [];
292
+
293
+ italicKeywordList.forEach(keyword => {
294
+ const kw = keyword.trim();
295
+ const kwRegex = new RegExp(`\\b${kw}\\b`, 'i');
296
+ if (kwRegex.test(fontTitle)) {
297
+ fontTitle = fontTitle.replace(kwRegex, '').trim();
298
+ italicKW.push(kw);
299
+ }
300
+ if (font?.fullName && typeof font.fullName === 'string' && font.fullName.toLowerCase().includes(kw.toLowerCase())) {
301
+ if (!italicKW.includes(kw)) italicKW.push(kw);
302
+ }
303
+ });
304
+
305
+ return italicKW;
306
+ };
307
+
308
+ /**
309
+ * Normalises and title-cases a font title, optionally expanding abbreviations.
310
+ * @param {string} fontTitle
311
+ * @param {boolean} preserveShortenedNames
312
+ * @returns {string}
313
+ */
314
+ export const formatFontTitle = (fontTitle, preserveShortenedNames = false) => {
315
+ const hasItalic = fontTitle.toLowerCase().includes('italic');
316
+ fontTitle = fontTitle.replace(/-/g, ' ');
317
+
318
+ return fontTitle.replace(/\s+/g, ' ').trim().split(' ').map(word => {
319
+ if (hasItalic && word.toLowerCase() === 'italic') return 'Italic';
320
+ let fullWord = word;
321
+ if (!preserveShortenedNames) {
322
+ fullWord = reverseSpellingLookup(word) || word;
323
+ }
324
+ return fullWord[0].toUpperCase() + fullWord.slice(1);
325
+ }).join(' ');
326
+ };
327
+
328
+ /**
329
+ * Appends any italic keywords to the font title that aren't already present.
330
+ * @param {Object} font
331
+ * @param {string} fontTitle
332
+ * @param {string[]} italicKW
333
+ * @param {string} style
334
+ * @param {boolean} preserveShortenedNames
335
+ * @returns {string}
336
+ */
337
+ export const addItalicToFontTitle = (font, fontTitle, italicKW, style, preserveShortenedNames = false) => {
338
+ const hasItalicAngle = font?.italicAngle !== 0;
339
+ const hasItalicInName = font?.fullName.toLowerCase().includes('italic');
340
+
341
+ if (italicKW.length > 0 || hasItalicAngle || hasItalicInName) {
342
+ italicKW = [...new Set(italicKW)];
343
+
344
+ if (italicKW.length === 0 && (hasItalicAngle || hasItalicInName)) {
345
+ italicKW = ['Italic'];
346
+ }
347
+
348
+ if (!preserveShortenedNames) {
349
+ italicKW = italicKW.map(item => reverseSpellingLookup(item) || item);
350
+ }
351
+
352
+ italicKW = [...new Set(italicKW)];
353
+
354
+ if (italicKW.length > 1 && italicKW.includes('Italic')) {
355
+ italicKW = ['Italic'];
356
+ }
357
+
358
+ const fontTitleLower = fontTitle.toLowerCase();
359
+ italicKW = italicKW.filter(keyword => {
360
+ const keywordLower = keyword.toLowerCase();
361
+ const kwRegex = new RegExp(`\\b${keywordLower}\\b`);
362
+ const isSubstring = fontTitleLower.split(' ').some(word =>
363
+ word.includes(keywordLower) || keywordLower.includes(word)
364
+ );
365
+ return !kwRegex.test(fontTitleLower) && !isSubstring;
366
+ });
367
+
368
+ if (italicKW.length > 0) {
369
+ fontTitle = fontTitle.trim() + ' ' + italicKW.join(' ');
370
+ }
371
+ }
372
+
373
+ return fontTitle;
374
+ };
375
+
376
+ /**
377
+ * Builds a font object ready for staging and upload.
378
+ * originalFilename is stored temporarily and deleted before saving to Sanity.
379
+ * @param {string} id
380
+ * @param {string} fontTitle
381
+ * @param {string} title
382
+ * @param {Object} font
383
+ * @param {boolean} variableFont
384
+ * @param {string} weightName
385
+ * @param {string} subfamilyName
386
+ * @param {File} file
387
+ * @param {string|null} originalFilename
388
+ * @returns {Object}
389
+ */
390
+ export const createFontObject = (id, fontTitle, title, font, variableFont, weightName, subfamilyName, file, originalFilename = null) => {
391
+ const fontObject = {
392
+ _key: nanoid(),
393
+ _id: id,
394
+ title: fontTitle,
395
+ slug: { _type: 'slug', current: id },
396
+ typefaceName: title,
397
+ style: (font?.italicAngle !== 0 || font?.fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular',
398
+ variableFont: variableFont,
399
+ weightName: weightName,
400
+ subfamily: subfamilyName,
401
+ normalWeight: true,
402
+ weight: Number(determineWeight(font, weightName)),
403
+ fileInput: {},
404
+ files: [file],
405
+ fontKit: font,
406
+ };
407
+
408
+ if (originalFilename) {
409
+ fontObject.originalFilename = originalFilename;
410
+ }
411
+
412
+ return fontObject;
413
+ };
414
+
415
+ /**
416
+ * Determines a numeric CSS weight value for a font.
417
+ * @param {Object} font
418
+ * @param {string} weightName
419
+ * @returns {number}
420
+ */
421
+ export const determineWeight = (font, weightName) => {
422
+ if (font['OS/2']?.usWeightClass) {
423
+ return Number(font['OS/2'].usWeightClass);
424
+ }
425
+
426
+ const wn = weightName?.toLowerCase() || '';
427
+
428
+ if (/hairline|extra thin|extrathin/.test(wn)) return 100;
429
+ if (/thin|extra light|extralight/.test(wn)) return 200;
430
+ if (/light|book/.test(wn)) return 300;
431
+ if (/regular|normal/.test(wn)) return 400;
432
+ if (/medium/.test(wn)) return 500;
433
+ if (/semi bold|semibold/.test(wn)) return 600;
434
+ if (/extra bold|extrabold/.test(wn)) return 800;
435
+ if (/bold/.test(wn)) return 700;
436
+ if (/black|ultra/.test(wn)) return 900;
437
+
438
+ return 400;
439
+ };
440
+
441
+ /**
442
+ * Sorts a map of font objects by ascending weight, with Regular before Italic at equal weights.
443
+ * @param {Object} fontsObjects
444
+ * @returns {Object}
445
+ */
446
+ export const sortFontObjects = (fontsObjects) => {
447
+ return Object.fromEntries(
448
+ Object.entries(fontsObjects).sort((a, b) => {
449
+ const weightA = Number(a[1].weight);
450
+ const weightB = Number(b[1].weight);
451
+ if (weightA === weightB) {
452
+ if (a[1].style === 'Regular' && b[1].style === 'Italic') return -1;
453
+ if (a[1].style === 'Italic' && b[1].style === 'Regular') return 1;
454
+ return 0;
455
+ }
456
+ return weightA - weightB;
457
+ })
458
+ );
459
+ };
460
+
461
+ /**
462
+ * Logs font metadata to the console for debugging.
463
+ * @param {string} id
464
+ * @param {string} fontTitle
465
+ * @param {Object} font
466
+ * @param {string} fileName
467
+ * @param {string} subfamilyName
468
+ * @param {string} style
469
+ * @param {string} weightName
470
+ * @param {boolean} variableFont
471
+ * @param {string[]} italicKW
472
+ */
473
+ export const logFontInfo = (id, fontTitle, font, fileName, subfamilyName, style, weightName, variableFont, italicKW) => {
474
+ console.log('=== Font Info ====');
475
+ console.log('Font id: ', id);
476
+ console.log('Font title: ', fontTitle);
477
+ console.log('Fontkit fullName: ', font.fullName);
478
+ console.log('Fontkit family name: ', font.familyName);
479
+ console.log('File name: ', fileName);
480
+ console.log('Subfamily: ', subfamilyName);
481
+ console.log('Style: ', style);
482
+ console.log('Weight: ', weightName);
483
+ console.log('Variable: ', variableFont);
484
+ console.log('italicKW: ', italicKW);
485
+ console.log('Font italic angle: ', (font?.italicAngle !== 0 || font?.fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular');
486
+ console.log('=======');
487
+ };