@liiift-studio/sanity-font-manager 2.2.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.
@@ -0,0 +1,99 @@
1
+ // Returns a zeroed-out fontkit-shaped placeholder object used when no font binary is available
2
+ import * as fontkit from 'fontkit';
3
+ import slugify from 'slugify';
4
+
5
+ /** Reads font files and returns name/subfamily metadata without writing to Sanity */
6
+ export async function getEmptyFontKit({ title, files, weightKeywordList, italicKeywordList }) {
7
+
8
+ let fontNames = {};
9
+ let subfamilies = {};
10
+
11
+ for (var i = 0; i < files.length; i++) {
12
+
13
+ const file = files[i];
14
+ const fontBuffer = await readFontFile(file);
15
+ const font = fontkit.create(fontBuffer);
16
+
17
+ let weightName = font?.name?.records?.preferredSubfamily ? font?.name?.records?.preferredSubfamily : font?.name?.records?.fontSubfamily;
18
+ weightName = weightName?.en ? weightName.en : weightName.constructor == Object ? weightName[Object.keys(weightName)[0]] : weightName;
19
+
20
+ let variableFont = font?.variationAxes && Object.keys(font.variationAxes).length > 0 ? true : false;
21
+ let subfamilyName = font.familyName.toLowerCase().trim().replace(title.toLowerCase().trim(), '').trim();
22
+ let fontTitle = font?.fullName.toLowerCase().trim();
23
+
24
+ weightKeywordList.forEach(keyword => {
25
+ const kw = keyword.toLowerCase().trim();
26
+
27
+ if (fontTitle.includes(kw)) {
28
+ fontTitle = fontTitle.replace(kw, '');
29
+ }
30
+ if (subfamilyName.includes(kw)) {
31
+ subfamilyName = subfamilyName.replace(kw, '');
32
+ }
33
+ });
34
+
35
+ italicKeywordList.forEach(keyword => {
36
+ const kw = keyword.toLowerCase().trim();
37
+
38
+ if (subfamilyName.includes(kw)) {
39
+ subfamilyName = subfamilyName.replace(kw, '');
40
+ }
41
+ if (fontTitle.includes(kw)) {
42
+ fontTitle = fontTitle.replace(kw, '');
43
+ }
44
+ });
45
+
46
+ fontTitle = fontTitle.trim().split(' ').map(word => word[0].toUpperCase() + word.slice(1)).join(' ');
47
+
48
+ subfamilyName = subfamilyName.trim();
49
+ subfamilyName = (subfamilyName == '') ? 'Regular' : subfamilyName.split(' ').map(word => word[0].toUpperCase() + word.slice(1)).join(' ');
50
+
51
+
52
+ let id = slugify(fontTitle.toLowerCase().trim());
53
+ if (variableFont && !id.endsWith('-vf')) {
54
+ id = id + '-vf';
55
+ fontTitle = fontTitle + ' VF';
56
+ }
57
+
58
+ // add subfamily to list
59
+ if (!subfamilies[id]) {
60
+ subfamilies[id] = [subfamilyName];
61
+ } else if (subfamilies[id].indexOf(subfamilyName) == -1) {
62
+ subfamilies[id] = [...subfamilies[id], subfamilyName];
63
+ }
64
+
65
+ if (!fontNames[id]) {
66
+ fontNames[id] = [{
67
+ file: file.name,
68
+ fullName: font.fullName,
69
+ familyName: font.familyName,
70
+ subFamily: subfamilyName,
71
+ }];
72
+ } else if (fontNames[id].indexOf(file.name) == -1) {
73
+ fontNames[id].push({
74
+ file: file.name,
75
+ fullName: font.fullName,
76
+ familyName: font.familyName,
77
+ subFamily: subfamilyName,
78
+ })
79
+ }
80
+
81
+
82
+ }
83
+
84
+ console.log('font names : ', fontNames);
85
+ }
86
+
87
+ /** Reads a font file and returns its content as a Uint8Array */
88
+ const readFontFile = (file) => {
89
+ return new Promise((resolve, reject) => {
90
+ const reader = new FileReader();
91
+
92
+ reader.onload = (event) => {
93
+ resolve(new Uint8Array(event.target.result));
94
+ };
95
+
96
+ reader.onerror = (error) => { reject(error); };
97
+ reader.readAsArrayBuffer(file);
98
+ });
99
+ };
@@ -0,0 +1,211 @@
1
+ // Resolves named variable font instances into Sanity font document references, creating documents for missing instances
2
+
3
+ import { nanoid } from 'nanoid';
4
+ import { expandAbbreviations } from './generateKeywords';
5
+
6
+ /**
7
+ * Parses a variable font's named instances and maps each to its corresponding static font document.
8
+ * Uses 6 matching strategies in priority order:
9
+ * 1. Exact title match
10
+ * 2. Title normalisation (strips VF/var/variable prefixes, handles Regular/Italic suffixes)
11
+ * 3. Abbreviation expansion
12
+ * 4. Weight + style matching
13
+ * 5. weightName comparison
14
+ * 6. metaData.fullName fallback
15
+ *
16
+ * @param {Object} font - The variable font object (must have typefaceName and variableInstances)
17
+ * @param {Object} client - Sanity client (parameterized queries only)
18
+ * @returns {Promise<Array>} Array of { key, value, _key } instance mappings
19
+ */
20
+ export const parseVariableFontInstances = async (font, client) => {
21
+ if (!font.variableFont || !font.variableInstances) return [];
22
+
23
+ let variableInstances;
24
+ try {
25
+ variableInstances = JSON.parse(font.variableInstances);
26
+ } catch (err) {
27
+ console.error('Error parsing variable instances:', err);
28
+ variableInstances = {};
29
+ }
30
+
31
+ if (Object.keys(variableInstances).length === 0) return [];
32
+
33
+ // Fetch the typeface's curated font list (parameterized — no injection risk)
34
+ let staticFonts;
35
+ const typeface = await client.fetch(
36
+ `*[_type == 'typeface' && title == $typefaceName][0]{
37
+ 'fonts': styles.fonts[]-> {
38
+ _id,
39
+ title,
40
+ subfamily,
41
+ style,
42
+ weight,
43
+ weightName,
44
+ metaData,
45
+ variableFont
46
+ }
47
+ }`,
48
+ { typefaceName: font.typefaceName }
49
+ );
50
+
51
+ if (typeface?.fonts && typeface.fonts.length > 0) {
52
+ staticFonts = typeface.fonts.filter(f => !f.variableFont);
53
+ console.log('Using curated typeface fonts list:', staticFonts.length, 'fonts');
54
+ } else {
55
+ console.warn('Typeface not found or no fonts in curated list, falling back to all fonts query');
56
+ staticFonts = await client.fetch(
57
+ `*[_type == 'font' && typefaceName == $typefaceName && variableFont != true]{
58
+ _id,
59
+ title,
60
+ subfamily,
61
+ style,
62
+ weight,
63
+ weightName,
64
+ metaData
65
+ }`,
66
+ { typefaceName: font.typefaceName }
67
+ );
68
+ }
69
+
70
+ console.log('Variable font instances:', Object.keys(variableInstances));
71
+ console.log('Available static fonts:', staticFonts.map(sf => sf.title));
72
+
73
+ const instanceMappings = [];
74
+
75
+ Object.keys(variableInstances).forEach(instanceName => {
76
+ let matchingFont = null;
77
+
78
+ // Strategy 1: Exact title match
79
+ matchingFont = staticFonts.find(sf => sf.title === instanceName);
80
+
81
+ // Strategy 2: Title normalisation — strip VF/var/variable prefix words, handle Regular/Italic
82
+ if (!matchingFont && staticFonts.some(sf => sf.metaData?.fullName)) {
83
+ matchingFont = staticFonts.find(sf => {
84
+ if (!sf.metaData?.fullName) return false;
85
+
86
+ let fullName = sf.metaData.fullName;
87
+
88
+ const WORDS_TO_REMOVE = ['VF', 'var', 'variable', 'VAR', 'vf'];
89
+ const variableName = font.metaData?.familyName
90
+ ?.replace(new RegExp(`\\b(${WORDS_TO_REMOVE.join('|')})\\b`, 'gi'), '')
91
+ .replace(/\s{2,}/g, ' ')
92
+ .trim();
93
+
94
+ if (variableName && fullName.startsWith(variableName)) {
95
+ fullName = fullName.substring(variableName.length).trim();
96
+ }
97
+
98
+ if (variableName) {
99
+ const words = variableName.split(/\s+/).map(w => w.trim()).filter(Boolean);
100
+ if (words.length > 0) {
101
+ const regex = new RegExp(`\\b(${words.join('|')})\\b`, 'gi');
102
+ const stripped = fullName.replace(regex, '').replace(/\s{2,}/g, ' ').trim();
103
+ if (stripped !== '') fullName = stripped;
104
+ }
105
+ }
106
+
107
+ if (fullName.startsWith(font.typefaceName)) {
108
+ fullName = fullName.substring(font.typefaceName.length).trim();
109
+ }
110
+
111
+ if (sf.style?.toLowerCase() === 'italic' &&
112
+ !fullName.toLowerCase().endsWith('italic') &&
113
+ !fullName.toLowerCase().endsWith('slanted')) {
114
+ fullName = fullName + ' Italic';
115
+ }
116
+
117
+ if (fullName.trim().toLowerCase().endsWith('regular')) {
118
+ if (instanceName.trim().toLowerCase() + ' regular' === fullName.trim().toLowerCase()) return true;
119
+ }
120
+ if (fullName.trim().toLowerCase().startsWith('regular')) {
121
+ if ('regular ' + instanceName.trim().toLowerCase() === fullName.trim().toLowerCase()) return true;
122
+ }
123
+ if (fullName.trim().toLowerCase().endsWith('italic')) {
124
+ if (instanceName.trim().toLowerCase().endsWith('italic')) {
125
+ const k = instanceName.trim().toLowerCase().slice(0, -6).trim() + ' regular italic';
126
+ if (k === fullName.trim().toLowerCase()) return true;
127
+ }
128
+ }
129
+
130
+ return fullName.trim().toLowerCase() === instanceName.trim().toLowerCase();
131
+ });
132
+ }
133
+
134
+ // Strategy 3: Abbreviation expansion
135
+ if (!matchingFont) {
136
+ const expandedName = instanceName.split(' ').map(word => expandAbbreviations(word)).join(' ');
137
+ matchingFont = staticFonts.find(sf => {
138
+ const nameWithoutTypeface = sf.title.replace(font.typefaceName, '').trim();
139
+ return nameWithoutTypeface === expandedName;
140
+ });
141
+ }
142
+
143
+ // Strategy 4: Weight + style matching
144
+ if (!matchingFont) {
145
+ const isItalic = instanceName.toLowerCase().includes('italic');
146
+ const weightTerms = [
147
+ { term: 'thin', weight: '100' },
148
+ { term: 'extralight', weight: '200' },
149
+ { term: 'extra light', weight: '200' },
150
+ { term: 'light', weight: '300' },
151
+ { term: 'regular', weight: '400' },
152
+ { term: 'normal', weight: '400' },
153
+ { term: 'medium', weight: '500' },
154
+ { term: 'semibold', weight: '600' },
155
+ { term: 'semi bold', weight: '600' },
156
+ { term: 'bold', weight: '700' },
157
+ { term: 'extrabold', weight: '800' },
158
+ { term: 'extra bold', weight: '800' },
159
+ { term: 'black', weight: '900' },
160
+ { term: 'heavy', weight: '900' },
161
+ ];
162
+
163
+ let instanceWeight = '400';
164
+ for (const { term, weight } of weightTerms) {
165
+ if (instanceName.toLowerCase().includes(term)) {
166
+ instanceWeight = weight;
167
+ break;
168
+ }
169
+ }
170
+
171
+ matchingFont = staticFonts.find(sf =>
172
+ sf.weight === instanceWeight &&
173
+ ((isItalic && sf.style === 'Italic') || (!isItalic && sf.style === 'Regular'))
174
+ );
175
+ }
176
+
177
+ // Strategy 5: weightName comparison
178
+ if (!matchingFont) {
179
+ matchingFont = staticFonts.find(sf => {
180
+ if (!sf.weightName) return false;
181
+ const cleanInstance = instanceName.toLowerCase().replace(/italic/i, '').trim();
182
+ const cleanWeight = sf.weightName.toLowerCase().replace(/italic/i, '').trim();
183
+ return cleanInstance === cleanWeight;
184
+ });
185
+ }
186
+
187
+ // Strategy 6: Legacy metaData.fullName fallback
188
+ if (!matchingFont && staticFonts.some(sf => sf.metaData?.fullName)) {
189
+ matchingFont = staticFonts.find(sf => {
190
+ if (!sf.metaData?.fullName) return false;
191
+ const typefacePattern = new RegExp(`^${font.typefaceName}\\s+`, 'i');
192
+ const stylePart = sf.metaData.fullName.replace(typefacePattern, '').trim();
193
+ return instanceName.toLowerCase() === stylePart.toLowerCase();
194
+ });
195
+ }
196
+
197
+ console.log(`Instance "${instanceName}" matched with:`, matchingFont ? matchingFont.title : 'No match found');
198
+
199
+ instanceMappings.push({
200
+ key: instanceName,
201
+ value: matchingFont
202
+ ? { _type: 'reference', _ref: matchingFont._id, _weak: true }
203
+ : null,
204
+ _key: nanoid(),
205
+ });
206
+ });
207
+
208
+ return instanceMappings;
209
+ };
210
+
211
+ export default parseVariableFontInstances;