@liiift-studio/sanity-font-manager 2.7.0 → 2.7.1

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +695 -437
  3. package/dist/index.js +1634 -17839
  4. package/dist/index.mjs +1551 -17745
  5. package/package.json +83 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -655
  7. package/src/components/BulkActions.jsx +99 -99
  8. package/src/components/ExistingDocumentResolver.jsx +152 -152
  9. package/src/components/FontReviewCard.jsx +455 -455
  10. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  11. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  12. package/src/components/KeyValueInput.jsx +95 -95
  13. package/src/components/KeyValueReferenceInput.jsx +267 -267
  14. package/src/components/NestedObjectArraySelector.jsx +146 -146
  15. package/src/components/PriceInput.jsx +26 -26
  16. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  17. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  18. package/src/components/SetOTF.jsx +87 -87
  19. package/src/components/SingleUploaderTool.jsx +674 -674
  20. package/src/components/StatusDisplay.jsx +26 -26
  21. package/src/components/StyleCountInput.jsx +16 -16
  22. package/src/components/UpdateScriptsComponent.jsx +76 -76
  23. package/src/components/UploadButton.jsx +43 -43
  24. package/src/components/UploadModal.jsx +309 -309
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -478
  28. package/src/components/UploadStep3Execute.jsx +234 -234
  29. package/src/components/UploadStep3bInstances.jsx +396 -396
  30. package/src/components/UploadSummary.jsx +196 -196
  31. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  32. package/src/hooks/useNestedObjects.js +92 -92
  33. package/src/hooks/useSanityClient.js +9 -9
  34. package/src/index.js +120 -120
  35. package/src/schema/openTypeField.js +1995 -1995
  36. package/src/schema/styleCountField.js +12 -12
  37. package/src/schema/stylesField.js +302 -302
  38. package/src/schema/stylisticSetField.js +301 -301
  39. package/src/utils/buildUploadPlan.js +326 -326
  40. package/src/utils/executeUploadPlan.js +430 -430
  41. package/src/utils/executionReducer.js +56 -56
  42. package/src/utils/fontHelpers.js +281 -281
  43. package/src/utils/generateCssFile.js +207 -207
  44. package/src/utils/generateFontData.js +98 -98
  45. package/src/utils/generateFontFile.js +38 -38
  46. package/src/utils/generateKeywords.js +185 -185
  47. package/src/utils/generateSubset.js +45 -45
  48. package/src/utils/getEmptyFontKit.js +101 -101
  49. package/src/utils/parseFont.js +56 -56
  50. package/src/utils/parseVariableFontInstances.js +301 -301
  51. package/src/utils/planReducer.js +531 -531
  52. package/src/utils/planTypes.js +183 -183
  53. package/src/utils/processFontFiles.js +530 -530
  54. package/src/utils/regenerateFontData.js +146 -146
  55. package/src/utils/resolveExistingFont.js +87 -87
  56. package/src/utils/retitleFontEntries.js +154 -154
  57. package/src/utils/sanitizeForSanityId.js +65 -65
  58. package/src/utils/setupDecompressors.js +27 -27
  59. package/src/utils/updateFontPrices.js +94 -94
  60. package/src/utils/updateTypefaceDocument.js +162 -162
  61. package/src/utils/uploadFontFiles.js +405 -405
  62. package/src/utils/utils.js +24 -24
@@ -1,301 +1,301 @@
1
- // Resolves named variable font instances into Sanity font document references with multi-pass subfamily-aware matching
2
-
3
- import { nanoid } from 'nanoid';
4
- import { expandAbbreviations } from './generateKeywords';
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
-
12
- /**
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
196
- *
197
- * This prevents a less-specific match from "stealing" a font that would be
198
- * the exact match for a different instance processed later.
199
- */
200
- export const parseVariableFontInstances = async (font, client) => {
201
- if (!font.variableFont || !font.variableInstances) return [];
202
-
203
- let variableInstances;
204
- try {
205
- variableInstances = JSON.parse(font.variableInstances);
206
- } catch (err) {
207
- console.error('Error parsing variable instances:', err);
208
- variableInstances = {};
209
- }
210
-
211
- if (Object.keys(variableInstances).length === 0) return [];
212
-
213
- // Fetch static fonts
214
- let staticFonts;
215
- const typeface = await client.fetch(
216
- `*[_type == 'typeface' && title == $typefaceName][0]{
217
- 'fonts': styles.fonts[]-> {
218
- _id, title, subfamily, style, weight, weightName, metaData, variableFont
219
- }
220
- }`,
221
- { typefaceName: font.typefaceName }
222
- );
223
-
224
- if (typeface?.fonts && typeface.fonts.length > 0) {
225
- staticFonts = typeface.fonts.filter(f => !f.variableFont);
226
- console.log('Using curated typeface fonts list:', staticFonts.length, 'fonts');
227
- } else {
228
- console.warn('Typeface not found or no fonts in curated list, falling back to all fonts query');
229
- staticFonts = await client.fetch(
230
- `*[_type == 'font' && typefaceName == $typefaceName && variableFont != true]{
231
- _id, title, subfamily, style, weight, weightName, metaData
232
- }`,
233
- { typefaceName: font.typefaceName }
234
- );
235
- }
236
-
237
- const instanceNames = Object.keys(variableInstances);
238
- console.log('Variable font instances:', instanceNames.length);
239
- console.log('Available static fonts:', staticFonts.length);
240
-
241
- // Parse all instance names upfront
242
- const parsedInstances = instanceNames.map(name => ({
243
- name,
244
- parsed: parseInstanceName(name),
245
- }));
246
-
247
- // Track results and claimed fonts
248
- const results = new Map(); // instanceName → { fontId, strategy }
249
- const claimedFontIds = new Set();
250
-
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;
255
-
256
- // Collect all potential matches for this pass (don't claim yet)
257
- const passMatches = [];
258
-
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));
263
-
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
- }
268
- }
269
-
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);
275
- }
276
- }
277
- }
278
-
279
- // Build output
280
- const matched = [...results.values()].length;
281
- console.log(`[parseVariableFontInstances] Matched ${matched}/${instanceNames.length} instances across ${STRATEGIES.length} passes`);
282
-
283
- const instanceMappings = instanceNames.map(instanceName => {
284
- const result = results.get(instanceName);
285
- const matchedFont = result ? staticFonts.find(sf => sf._id === result.fontId) : null;
286
-
287
- console.log(`Instance "${instanceName}" → ${matchedFont ? `${matchedFont.title} (${result.strategy})` : 'No match'}`);
288
-
289
- return {
290
- key: instanceName,
291
- value: matchedFont
292
- ? { _type: 'reference', _ref: matchedFont._id, _weak: true }
293
- : null,
294
- _key: nanoid(),
295
- };
296
- });
297
-
298
- return instanceMappings;
299
- };
300
-
301
- export default parseVariableFontInstances;
1
+ // Resolves named variable font instances into Sanity font document references with multi-pass subfamily-aware matching
2
+
3
+ import { nanoid } from 'nanoid';
4
+ import { expandAbbreviations } from './generateKeywords';
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
+
12
+ /**
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
196
+ *
197
+ * This prevents a less-specific match from "stealing" a font that would be
198
+ * the exact match for a different instance processed later.
199
+ */
200
+ export const parseVariableFontInstances = async (font, client) => {
201
+ if (!font.variableFont || !font.variableInstances) return [];
202
+
203
+ let variableInstances;
204
+ try {
205
+ variableInstances = JSON.parse(font.variableInstances);
206
+ } catch (err) {
207
+ console.error('Error parsing variable instances:', err);
208
+ variableInstances = {};
209
+ }
210
+
211
+ if (Object.keys(variableInstances).length === 0) return [];
212
+
213
+ // Fetch static fonts
214
+ let staticFonts;
215
+ const typeface = await client.fetch(
216
+ `*[_type == 'typeface' && title == $typefaceName][0]{
217
+ 'fonts': styles.fonts[]-> {
218
+ _id, title, subfamily, style, weight, weightName, metaData, variableFont
219
+ }
220
+ }`,
221
+ { typefaceName: font.typefaceName }
222
+ );
223
+
224
+ if (typeface?.fonts && typeface.fonts.length > 0) {
225
+ staticFonts = typeface.fonts.filter(f => !f.variableFont);
226
+ console.log('Using curated typeface fonts list:', staticFonts.length, 'fonts');
227
+ } else {
228
+ console.warn('Typeface not found or no fonts in curated list, falling back to all fonts query');
229
+ staticFonts = await client.fetch(
230
+ `*[_type == 'font' && typefaceName == $typefaceName && variableFont != true]{
231
+ _id, title, subfamily, style, weight, weightName, metaData
232
+ }`,
233
+ { typefaceName: font.typefaceName }
234
+ );
235
+ }
236
+
237
+ const instanceNames = Object.keys(variableInstances);
238
+ console.log('Variable font instances:', instanceNames.length);
239
+ console.log('Available static fonts:', staticFonts.length);
240
+
241
+ // Parse all instance names upfront
242
+ const parsedInstances = instanceNames.map(name => ({
243
+ name,
244
+ parsed: parseInstanceName(name),
245
+ }));
246
+
247
+ // Track results and claimed fonts
248
+ const results = new Map(); // instanceName → { fontId, strategy }
249
+ const claimedFontIds = new Set();
250
+
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;
255
+
256
+ // Collect all potential matches for this pass (don't claim yet)
257
+ const passMatches = [];
258
+
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));
263
+
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
+ }
268
+ }
269
+
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);
275
+ }
276
+ }
277
+ }
278
+
279
+ // Build output
280
+ const matched = [...results.values()].length;
281
+ console.log(`[parseVariableFontInstances] Matched ${matched}/${instanceNames.length} instances across ${STRATEGIES.length} passes`);
282
+
283
+ const instanceMappings = instanceNames.map(instanceName => {
284
+ const result = results.get(instanceName);
285
+ const matchedFont = result ? staticFonts.find(sf => sf._id === result.fontId) : null;
286
+
287
+ console.log(`Instance "${instanceName}" → ${matchedFont ? `${matchedFont.title} (${result.strategy})` : 'No match'}`);
288
+
289
+ return {
290
+ key: instanceName,
291
+ value: matchedFont
292
+ ? { _type: 'reference', _ref: matchedFont._id, _weak: true }
293
+ : null,
294
+ _key: nanoid(),
295
+ };
296
+ });
297
+
298
+ return instanceMappings;
299
+ };
300
+
301
+ export default parseVariableFontInstances;