@liiift-studio/sanity-font-manager 2.5.0 → 2.5.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.
@@ -1,21 +1,201 @@
1
- // Resolves named variable font instances into Sanity font document references, creating documents for missing instances
1
+ // Resolves named variable font instances into Sanity font document references with multi-pass subfamily-aware matching
2
2
 
3
3
  import { nanoid } from 'nanoid';
4
4
  import { expandAbbreviations } from './generateKeywords';
5
5
 
6
+ /** Known width prefixes — longest first to match XXNarrow before Narrow */
7
+ const WIDTH_PREFIXES = [
8
+ 'XXXWide', 'XXWide', 'XWide', 'Wide',
9
+ 'XXXNarrow', 'XXNarrow', 'XNarrow', 'Narrow',
10
+ ];
11
+
6
12
  /**
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
13
+ * Parses a VF instance name into subfamily (width), weight, and style components.
14
+ * e.g. "XXNarrow Bold Slant" { subfamily: "XXNarrow", weight: "Bold", style: "Slant" }
15
+ */
16
+ function parseInstanceName(instanceName) {
17
+ let subfamily = '';
18
+ let remaining = instanceName.trim();
19
+
20
+ for (const prefix of WIDTH_PREFIXES) {
21
+ if (remaining.toLowerCase().startsWith(prefix.toLowerCase() + ' ') || remaining.toLowerCase() === prefix.toLowerCase()) {
22
+ subfamily = prefix;
23
+ remaining = remaining.substring(prefix.length).trim();
24
+ break;
25
+ }
26
+ }
27
+
28
+ let style = '';
29
+ for (const suffix of ['Backslant', 'Slant', 'Italic', 'Oblique']) {
30
+ if (remaining.toLowerCase().endsWith(' ' + suffix.toLowerCase()) || remaining.toLowerCase() === suffix.toLowerCase()) {
31
+ style = suffix;
32
+ remaining = remaining.substring(0, remaining.length - suffix.length).trim();
33
+ break;
34
+ }
35
+ }
36
+
37
+ return { subfamily, weight: remaining || 'Regular', style };
38
+ }
39
+
40
+ /**
41
+ * Filters static fonts to the same subfamily, preventing cross-subfamily matches.
42
+ */
43
+ function filterBySubfamily(staticFonts, instanceSubfamily, typefaceName) {
44
+ if (!instanceSubfamily) {
45
+ return staticFonts.filter(sf => {
46
+ const sub = (sf.subfamily || '').toLowerCase();
47
+ if (sub === '' || sub === 'regular') return true;
48
+ const afterTypeface = (sf.title || '').replace(typefaceName, '').trim();
49
+ return !WIDTH_PREFIXES.some(p => afterTypeface.toLowerCase().startsWith(p.toLowerCase()));
50
+ });
51
+ }
52
+ const lowerSf = instanceSubfamily.toLowerCase();
53
+ const expanded = (expandAbbreviations(instanceSubfamily) || '').toLowerCase();
54
+ return staticFonts.filter(sf => {
55
+ const sub = (sf.subfamily || '').toLowerCase();
56
+ if (sub === lowerSf || (expanded && sub === expanded)) return true;
57
+ const afterTypeface = (sf.title || '').replace(typefaceName, '').trim().toLowerCase();
58
+ if (afterTypeface.startsWith(lowerSf)) return true;
59
+ if (expanded && afterTypeface.startsWith(expanded)) return true;
60
+ return false;
61
+ });
62
+ }
63
+
64
+ /** Weight keyword → numeric weight mapping */
65
+ const WEIGHT_MAP = [
66
+ { term: 'ultra', weight: 950 },
67
+ { term: 'xxlight', weight: 200 },
68
+ { term: 'xlight', weight: 250 },
69
+ { term: 'extralight', weight: 200 },
70
+ { term: 'extra light', weight: 200 },
71
+ { term: 'thin', weight: 100 },
72
+ { term: 'hairline', weight: 100 },
73
+ { term: 'light', weight: 300 },
74
+ { term: 'regular', weight: 400 },
75
+ { term: 'normal', weight: 400 },
76
+ { term: 'medium', weight: 500 },
77
+ { term: 'semibold', weight: 600 },
78
+ { term: 'semi bold', weight: 600 },
79
+ { term: 'extrabold', weight: 800 },
80
+ { term: 'extra bold', weight: 800 },
81
+ { term: 'xbold', weight: 800 },
82
+ { term: 'bold', weight: 700 },
83
+ { term: 'black', weight: 900 },
84
+ { term: 'heavy', weight: 900 },
85
+ ];
86
+
87
+ /** Converts a weight name to a numeric weight */
88
+ function weightFromName(name) {
89
+ const lower = name.toLowerCase();
90
+ for (const { term, weight } of WEIGHT_MAP) {
91
+ if (lower === term || lower.includes(term)) return weight;
92
+ }
93
+ return 400;
94
+ }
95
+
96
+ /**
97
+ * Multi-pass matching strategies, ordered from most confident to least.
98
+ * Each returns a match function: (instanceName, parsed, candidates, typefaceName, font) => matchedFont|null
99
+ */
100
+ const STRATEGIES = [
101
+ // Pass 1: Exact title match (with typeface prefix)
102
+ {
103
+ name: 'exact-title',
104
+ match: (instanceName, parsed, candidates, typefaceName) => {
105
+ const withPrefix = `${typefaceName} ${instanceName}`;
106
+ return candidates.find(sf => sf.title === instanceName || sf.title === withPrefix) || null;
107
+ },
108
+ },
109
+ // Pass 2: Title normalisation — strip typeface name and compare remainder
110
+ {
111
+ name: 'title-normalised',
112
+ match: (instanceName, parsed, candidates, typefaceName) => {
113
+ return candidates.find(sf => {
114
+ const sfName = (sf.title || '').replace(typefaceName, '').trim();
115
+ if (sfName.toLowerCase() === instanceName.toLowerCase()) return true;
116
+ // Handle "Regular" suffix: instance "Narrow Regular" → font title remainder "Narrow"
117
+ if (parsed.weight === 'Regular' && !parsed.style) {
118
+ if (sfName.toLowerCase() === parsed.subfamily.toLowerCase()) return true;
119
+ }
120
+ return false;
121
+ }) || null;
122
+ },
123
+ },
124
+ // Pass 3: Abbreviation expansion (XLight → ExtraLight, XBold → ExtraBold)
125
+ {
126
+ name: 'abbreviation',
127
+ match: (instanceName, parsed, candidates, typefaceName) => {
128
+ const expandedFull = instanceName.split(' ').map(w => expandAbbreviations(w) || w).join(' ');
129
+ let found = candidates.find(sf => {
130
+ const sfName = (sf.title || '').replace(typefaceName, '').trim();
131
+ return sfName.toLowerCase() === expandedFull.toLowerCase();
132
+ });
133
+ if (found) return found;
134
+
135
+ // Try expanding just the weight part and rebuilding
136
+ const expandedWeight = expandAbbreviations(parsed.weight) || parsed.weight;
137
+ const target = [parsed.subfamily, expandedWeight, parsed.style].filter(Boolean).join(' ');
138
+ return candidates.find(sf => {
139
+ const sfName = (sf.title || '').replace(typefaceName, '').trim();
140
+ return sfName.toLowerCase() === target.toLowerCase();
141
+ }) || null;
142
+ },
143
+ },
144
+ // Pass 4: fullName metadata comparison
145
+ {
146
+ name: 'metadata-fullName',
147
+ match: (instanceName, parsed, candidates, typefaceName) => {
148
+ return candidates.find(sf => {
149
+ if (!sf.metaData?.fullName) return false;
150
+ const typefacePattern = new RegExp(`^${typefaceName}\\s+`, 'i');
151
+ const stylePart = sf.metaData.fullName.replace(typefacePattern, '').trim();
152
+ return instanceName.toLowerCase() === stylePart.toLowerCase();
153
+ }) || null;
154
+ },
155
+ },
156
+ // Pass 5: Weight + style matching (numeric, within subfamily)
157
+ {
158
+ name: 'weight-style',
159
+ match: (instanceName, parsed, candidates) => {
160
+ const instanceWeight = weightFromName(parsed.weight);
161
+ const isBackslant = parsed.style.toLowerCase() === 'backslant';
162
+ const isSlant = parsed.style.toLowerCase() === 'slant';
163
+ const isItalic = parsed.style.toLowerCase() === 'italic';
164
+
165
+ return candidates.find(sf => {
166
+ if (Number(sf.weight) !== instanceWeight) return false;
167
+ if (isBackslant) return sf.style === 'Italic' && sf.title?.toLowerCase().includes('backslant');
168
+ if (isSlant) return sf.style === 'Italic' && !sf.title?.toLowerCase().includes('backslant');
169
+ if (isItalic) return sf.style === 'Italic';
170
+ return sf.style === 'Regular';
171
+ }) || null;
172
+ },
173
+ },
174
+ // Pass 6: weightName string comparison
175
+ {
176
+ name: 'weightName',
177
+ match: (instanceName, parsed, candidates) => {
178
+ const cleanInstance = parsed.weight.toLowerCase().trim();
179
+ return candidates.find(sf => {
180
+ if (!sf.weightName) return false;
181
+ const cleanWeight = sf.weightName.toLowerCase().replace(/italic|slant|backslant/gi, '').trim();
182
+ return cleanInstance === cleanWeight;
183
+ }) || null;
184
+ },
185
+ },
186
+ ];
187
+
188
+ /**
189
+ * Multi-pass variable font instance matcher.
190
+ *
191
+ * For each strategy (most confident first):
192
+ * 1. Try to match ALL unmatched instances against ALL unclaimed fonts
193
+ * 2. Collect all matches for this pass
194
+ * 3. Claim matched fonts, remove from both pools
195
+ * 4. Move to next strategy with remaining unmatched
15
196
  *
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
197
+ * This prevents a less-specific match from "stealing" a font that would be
198
+ * the exact match for a different instance processed later.
19
199
  */
20
200
  export const parseVariableFontInstances = async (font, client) => {
21
201
  if (!font.variableFont || !font.variableInstances) return [];
@@ -30,19 +210,12 @@ export const parseVariableFontInstances = async (font, client) => {
30
210
 
31
211
  if (Object.keys(variableInstances).length === 0) return [];
32
212
 
33
- // Fetch the typeface's curated font list (parameterized — no injection risk)
213
+ // Fetch static fonts
34
214
  let staticFonts;
35
215
  const typeface = await client.fetch(
36
216
  `*[_type == 'typeface' && title == $typefaceName][0]{
37
217
  'fonts': styles.fonts[]-> {
38
- _id,
39
- title,
40
- subfamily,
41
- style,
42
- weight,
43
- weightName,
44
- metaData,
45
- variableFont
218
+ _id, title, subfamily, style, weight, weightName, metaData, variableFont
46
219
  }
47
220
  }`,
48
221
  { typefaceName: font.typefaceName }
@@ -55,154 +228,71 @@ export const parseVariableFontInstances = async (font, client) => {
55
228
  console.warn('Typeface not found or no fonts in curated list, falling back to all fonts query');
56
229
  staticFonts = await client.fetch(
57
230
  `*[_type == 'font' && typefaceName == $typefaceName && variableFont != true]{
58
- _id,
59
- title,
60
- subfamily,
61
- style,
62
- weight,
63
- weightName,
64
- metaData
231
+ _id, title, subfamily, style, weight, weightName, metaData
65
232
  }`,
66
233
  { typefaceName: font.typefaceName }
67
234
  );
68
235
  }
69
236
 
70
- console.log('Variable font instances:', Object.keys(variableInstances));
71
- console.log('Available static fonts:', staticFonts.map(sf => sf.title));
237
+ const instanceNames = Object.keys(variableInstances);
238
+ console.log('Variable font instances:', instanceNames.length);
239
+ console.log('Available static fonts:', staticFonts.length);
72
240
 
73
- const instanceMappings = [];
241
+ // Parse all instance names upfront
242
+ const parsedInstances = instanceNames.map(name => ({
243
+ name,
244
+ parsed: parseInstanceName(name),
245
+ }));
74
246
 
75
- Object.keys(variableInstances).forEach(instanceName => {
76
- let matchingFont = null;
247
+ // Track results and claimed fonts
248
+ const results = new Map(); // instanceName → { fontId, strategy }
249
+ const claimedFontIds = new Set();
77
250
 
78
- // Strategy 1: Exact title match
79
- matchingFont = staticFonts.find(sf => sf.title === instanceName);
251
+ // Multi-pass: each strategy gets a full pass over all remaining unmatched instances
252
+ for (const strategy of STRATEGIES) {
253
+ const unmatched = parsedInstances.filter(inst => !results.has(inst.name));
254
+ if (unmatched.length === 0) break;
80
255
 
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
- }
256
+ // Collect all potential matches for this pass (don't claim yet)
257
+ const passMatches = [];
129
258
 
130
- return fullName.trim().toLowerCase() === instanceName.trim().toLowerCase();
131
- });
132
- }
259
+ for (const inst of unmatched) {
260
+ // Get subfamily-scoped candidates that haven't been claimed
261
+ const subfamilyCandidates = filterBySubfamily(staticFonts, inst.parsed.subfamily, font.typefaceName)
262
+ .filter(sf => !claimedFontIds.has(sf._id));
133
263
 
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
- });
264
+ const match = strategy.match(inst.name, inst.parsed, subfamilyCandidates, font.typefaceName, font);
265
+ if (match) {
266
+ passMatches.push({ instanceName: inst.name, font: match, strategy: strategy.name });
267
+ }
141
268
  }
142
269
 
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
- }
270
+ // Claim matches if multiple instances matched the same font, the first one wins
271
+ for (const m of passMatches) {
272
+ if (!claimedFontIds.has(m.font._id) && !results.has(m.instanceName)) {
273
+ results.set(m.instanceName, { fontId: m.font._id, strategy: m.strategy });
274
+ claimedFontIds.add(m.font._id);
169
275
  }
170
-
171
- matchingFont = staticFonts.find(sf =>
172
- sf.weight === instanceWeight &&
173
- ((isItalic && sf.style === 'Italic') || (!isItalic && sf.style === 'Regular'))
174
- );
175
276
  }
277
+ }
176
278
 
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
- }
279
+ // Build output
280
+ const matched = [...results.values()].length;
281
+ console.log(`[parseVariableFontInstances] Matched ${matched}/${instanceNames.length} instances across ${STRATEGIES.length} passes`);
186
282
 
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
- }
283
+ const instanceMappings = instanceNames.map(instanceName => {
284
+ const result = results.get(instanceName);
285
+ const matchedFont = result ? staticFonts.find(sf => sf._id === result.fontId) : null;
196
286
 
197
- console.log(`Instance "${instanceName}" matched with:`, matchingFont ? matchingFont.title : 'No match found');
287
+ console.log(`Instance "${instanceName}" ${matchedFont ? `${matchedFont.title} (${result.strategy})` : 'No match'}`);
198
288
 
199
- instanceMappings.push({
289
+ return {
200
290
  key: instanceName,
201
- value: matchingFont
202
- ? { _type: 'reference', _ref: matchingFont._id, _weak: true }
291
+ value: matchedFont
292
+ ? { _type: 'reference', _ref: matchedFont._id, _weak: true }
203
293
  : null,
204
294
  _key: nanoid(),
205
- });
295
+ };
206
296
  });
207
297
 
208
298
  return instanceMappings;
@@ -168,16 +168,17 @@ export const extractFontMetadata = (font, title, weightKeywordList, italicKeywor
168
168
 
169
169
  const fullName = getNameString(font, 4) || ttfFallbackMeta?.fullName || '';
170
170
 
171
- if ((weightName === '' || weightName.toLowerCase() === 'roman') && fullName) {
171
+ const axes = getVariationAxes(font);
172
+ const variableFont = axes !== null;
173
+
174
+ // For non-VF fonts, fall back to extracting weight from fullName when weightName is empty
175
+ if (!variableFont && (weightName === '' || weightName.toLowerCase() === 'roman') && fullName) {
172
176
  weightName = extractWeightFromFullName(font, title, ttfFallbackMeta);
173
177
  if (!preserveShortenedNames) {
174
178
  weightName = expandAbbreviations(weightName);
175
179
  }
176
180
  }
177
181
 
178
- const axes = getVariationAxes(font);
179
- const variableFont = axes !== null;
180
-
181
182
  // Subfamily detection — extract width/optical variant from name table.
182
183
  // Primary: nameId4 (fullName) minus typeface title — the most complete name record,
183
184
  // always contains width + weight (e.g. "Gear XXNarrow Regular" → "XXNarrow Regular").
@@ -38,9 +38,22 @@ export const updateTypefaceDocument = async (
38
38
 
39
39
  // Use dot-path keys so .set() does not clobber sibling fields
40
40
  // (styles.collections, styles.pairs, styles.free, styles.displayStyles)
41
+ // Deduplicate by _ref to prevent duplicate entries on re-upload
42
+ const dedupeRefs = (existing, incoming) => {
43
+ const merged = [...(existing || [])];
44
+ const existingRefs = new Set(merged.map(r => r._ref).filter(Boolean));
45
+ incoming.forEach(ref => {
46
+ if (ref._ref && !existingRefs.has(ref._ref)) {
47
+ merged.push(ref);
48
+ existingRefs.add(ref._ref);
49
+ }
50
+ });
51
+ return merged;
52
+ };
53
+
41
54
  let patch = {
42
- 'styles.fonts': stylesObject.fonts ? [...stylesObject.fonts, ...fontRefs] : [...fontRefs],
43
- 'styles.variableFont': stylesObject?.variableFont ? [...stylesObject.variableFont, ...variableRefs] : [...variableRefs],
55
+ 'styles.fonts': dedupeRefs(stylesObject.fonts, fontRefs),
56
+ 'styles.variableFont': dedupeRefs(stylesObject?.variableFont, variableRefs),
44
57
  };
45
58
 
46
59
  setStatus('Organising font subfamilies...');
@@ -1,6 +0,0 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
-
3
- var _chunkFH4QKHOHjs = require('./chunk-FH4QKHOH.js');
4
-
5
-
6
- exports.default = _chunkFH4QKHOHjs.UploadModal;