@liiift-studio/sanity-font-manager 2.7.0 → 2.8.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +695 -437
  3. package/dist/index.js +2330 -7084
  4. package/dist/index.mjs +2331 -7085
  5. package/package.json +84 -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,430 +1,430 @@
1
- // Phase 2 executor — uploads assets, creates/updates font documents, patches the typeface document
2
-
3
- import { nanoid } from 'nanoid';
4
- import generateCssFile from './generateCssFile';
5
- import generateFontData from './generateFontData';
6
- import { parseVariableFontInstances } from './parseVariableFontInstances';
7
- import { updateTypefaceDocument } from './updateTypefaceDocument';
8
- import {
9
- FONT_STATUS,
10
- EXECUTION_STATUS,
11
- RECOMMENDATION,
12
- CONCURRENCY_LIMIT,
13
- MAX_RETRIES,
14
- backoffWithJitter,
15
- } from './planTypes';
16
-
17
- /**
18
- * Phase 2: Executes a finalized plan — uploads assets, creates/updates
19
- * font documents, patches the typeface document.
20
- *
21
- * Skips fonts with status 'error'. Caches asset references in progress
22
- * for idempotent retry on partial failure.
23
- *
24
- * @param {object} params
25
- * @param {object} params.plan - The reviewed and finalized UploadPlan
26
- * @param {object} params.client - Sanity client
27
- * @param {string} params.docId - Typeface document _id
28
- * @param {object} params.stylesObject - Existing typeface styles
29
- * @param {object} params.preferredStyleRef - Current preferredStyle reference
30
- * @param {function} params.onProgress - Execution progress callback
31
- * @returns {Promise<object>} ExecutionResult
32
- */
33
- export async function executeUploadPlan({
34
- plan,
35
- client,
36
- docId,
37
- stylesObject = {},
38
- preferredStyleRef = {},
39
- onProgress,
40
- }) {
41
- const result = {
42
- success: true,
43
- created: 0,
44
- updated: 0,
45
- failed: 0,
46
- skipped: 0,
47
- failedFonts: [],
48
- fontRefs: [],
49
- variableRefs: [],
50
- typefacePatchError: null,
51
- };
52
-
53
- // Build execution queue — skip fonts with processing errors
54
- const fontEntries = Object.values(plan.fonts);
55
- const queue = fontEntries.filter(entry => entry.status !== FONT_STATUS.ERROR);
56
- const skipped = fontEntries.filter(entry => entry.status === FONT_STATUS.ERROR);
57
- result.skipped = skipped.length;
58
-
59
- // Track per-font execution progress
60
- const progress = {};
61
- for (const entry of queue) {
62
- progress[entry.tempId] = {
63
- status: EXECUTION_STATUS.QUEUED,
64
- currentFile: null,
65
- filesComplete: 0,
66
- filesTotal: entry.files.length,
67
- assetRefs: {},
68
- error: null,
69
- };
70
- }
71
-
72
- if (onProgress) {
73
- onProgress({ type: 'execution-start', totalFonts: queue.length, skippedFonts: result.skipped });
74
- }
75
-
76
- // Process fonts with concurrency limit
77
- const chunks = [];
78
- for (let i = 0; i < queue.length; i += CONCURRENCY_LIMIT) {
79
- chunks.push(queue.slice(i, i + CONCURRENCY_LIMIT));
80
- }
81
-
82
- let newPreferredStyle = { weight: -100, style: 'Italic', _ref: '' };
83
- const subfamilies = {};
84
- const uniqueSubfamilies = new Set();
85
-
86
- for (const chunk of chunks) {
87
- const chunkResults = await Promise.allSettled(
88
- chunk.map(entry => executeSingleFont({
89
- entry,
90
- plan,
91
- client,
92
- progress,
93
- onProgress,
94
- }))
95
- );
96
-
97
- for (let i = 0; i < chunkResults.length; i++) {
98
- const chunkResult = chunkResults[i];
99
- const entry = chunk[i];
100
-
101
- if (chunkResult.status === 'fulfilled' && chunkResult.value) {
102
- const fontResult = chunkResult.value;
103
-
104
- if (fontResult.isNew) result.created++;
105
- else result.updated++;
106
-
107
- // Track for typeface patch
108
- if (entry.variableFont) {
109
- result.variableRefs.push(fontResult.ref);
110
- } else {
111
- result.fontRefs.push(fontResult.ref);
112
- }
113
-
114
- subfamilies[entry.documentId] = entry.subfamily;
115
- if (entry.subfamily) uniqueSubfamilies.add(entry.subfamily);
116
-
117
- // Track preferred style candidate
118
- if (entry.weight > newPreferredStyle.weight) {
119
- newPreferredStyle = {
120
- weight: entry.weight,
121
- style: entry.style,
122
- _ref: fontResult.ref._ref,
123
- };
124
- }
125
- } else {
126
- result.failed++;
127
- result.success = false;
128
- const errorMsg = chunkResult.reason?.message || chunkResult.value?.error || 'Unknown error';
129
- result.failedFonts.push({
130
- tempId: entry.tempId,
131
- title: entry.title,
132
- error: errorMsg,
133
- failedAt: progress[entry.tempId]?.status || 'unknown',
134
- });
135
- }
136
- }
137
- }
138
-
139
- // Patch the typeface document with new font references
140
- if (result.fontRefs.length > 0 || result.variableRefs.length > 0) {
141
- try {
142
- if (onProgress) {
143
- onProgress({ type: 'typeface-patching' });
144
- }
145
-
146
- await updateTypefaceDocument(
147
- docId,
148
- result.fontRefs,
149
- result.variableRefs,
150
- subfamilies,
151
- [...uniqueSubfamilies],
152
- stylesObject?.subfamilies || [],
153
- preferredStyleRef,
154
- newPreferredStyle,
155
- stylesObject,
156
- client,
157
- (msg) => { if (onProgress) onProgress({ type: 'typeface-status', message: msg }); },
158
- (err) => { if (err) console.error('Typeface patch error flag set'); },
159
- );
160
-
161
- if (onProgress) {
162
- onProgress({ type: 'typeface-patched' });
163
- }
164
- } catch (err) {
165
- result.typefacePatchError = err.message;
166
- result.success = false;
167
- console.error('Typeface patch failed:', err.message);
168
-
169
- if (onProgress) {
170
- onProgress({ type: 'typeface-error', error: err.message });
171
- }
172
- }
173
- }
174
-
175
- if (onProgress) {
176
- onProgress({ type: 'execution-complete', result });
177
- }
178
-
179
- return result;
180
- }
181
-
182
- /**
183
- * Executes upload for a single font entry.
184
- * @returns {Promise<{ ref: object, isNew: boolean }>}
185
- */
186
- async function executeSingleFont({ entry, plan, client, progress, onProgress }) {
187
- const fontProgress = progress[entry.tempId];
188
- fontProgress.status = EXECUTION_STATUS.UPLOADING_ASSETS;
189
-
190
- if (onProgress) {
191
- onProgress({ type: 'font-upload-start', tempId: entry.tempId, fontProgress: { ...fontProgress } });
192
- }
193
-
194
- // Determine action based on resolution
195
- const decision = entry.decisions.existingDocument;
196
- const userChoice = decision.userChoice;
197
- const recommendation = decision.recommendation;
198
- const shouldUpdate = userChoice === 'update' ||
199
- (!userChoice && (recommendation === RECOMMENDATION.USE_EXACT || recommendation === RECOMMENDATION.USE_CANDIDATE));
200
- const existingDoc = shouldUpdate
201
- ? (decision.selectedCandidate || decision.exact || decision.candidates[0])
202
- : null;
203
-
204
- // Upload font files
205
- const fileInput = {};
206
- for (let j = 0; j < entry.files.length; j++) {
207
- const file = entry.files[j];
208
- const fileType = determineFileType(file);
209
- if (!fileType) continue;
210
-
211
- // Skip if already uploaded (idempotent retry)
212
- if (fontProgress.assetRefs[fileType]) {
213
- fileInput[fileType] = {
214
- _type: 'file',
215
- asset: { _type: 'reference', _ref: fontProgress.assetRefs[fileType] },
216
- };
217
- fontProgress.filesComplete++;
218
- continue;
219
- }
220
-
221
- fontProgress.currentFile = fileType;
222
-
223
- try {
224
- const assetFilename = plan.settings.preserveFileNames && entry.originalFilename
225
- ? `${entry.originalFilename}.${fileType}`
226
- : `${entry.documentId}.${fileType}`;
227
-
228
- const baseAsset = await uploadWithRetry(
229
- () => client.assets.upload('file', file, { filename: assetFilename }),
230
- );
231
-
232
- // Override Sanity SHA1-dedup originalFilename if needed
233
- if (plan.settings.preserveFileNames && baseAsset.originalFilename !== assetFilename) {
234
- try {
235
- await client.patch(baseAsset._id).set({ originalFilename: assetFilename }).commit();
236
- } catch (renameErr) {
237
- console.warn('Could not rename asset:', renameErr.message);
238
- }
239
- }
240
-
241
- fileInput[fileType] = {
242
- _type: 'file',
243
- asset: { _type: 'reference', _ref: baseAsset._id },
244
- };
245
- fontProgress.assetRefs[fileType] = baseAsset._id;
246
- fontProgress.filesComplete++;
247
-
248
- if (onProgress) {
249
- onProgress({ type: 'file-uploaded', tempId: entry.tempId, fileType, fontProgress: { ...fontProgress } });
250
- }
251
- } catch (err) {
252
- fontProgress.status = EXECUTION_STATUS.ERROR;
253
- fontProgress.error = err.message;
254
- throw new Error(`Asset upload failed for ${fileType}: ${err.message}`);
255
- }
256
- }
257
-
258
- // Generate CSS from WOFF2 if available
259
- if (fileInput.woff2 || fileInput.woff) {
260
- fontProgress.status = EXECUTION_STATUS.GENERATING_CSS;
261
- try {
262
- const woff2File = entry.files.find(f => f.name.endsWith('.woff2') || f.name.endsWith('.woff'));
263
- if (woff2File) {
264
- const updatedFileInput = await generateCssFile({
265
- woff2File,
266
- fileInput,
267
- fileName: entry.documentId,
268
- fontName: entry.title,
269
- variableFont: entry.variableFont,
270
- weight: entry.weight,
271
- style: entry.style,
272
- client,
273
- });
274
- Object.assign(fileInput, updatedFileInput);
275
-
276
- if (onProgress) {
277
- onProgress({ type: 'css-generated', tempId: entry.tempId, fontProgress: { ...fontProgress } });
278
- }
279
- }
280
- } catch (err) {
281
- console.warn('CSS generation failed for', entry.title, '— document created without CSS:', err.message);
282
- }
283
- }
284
-
285
- // Generate font metadata
286
- if (fileInput.ttf || fileInput.otf) {
287
- fontProgress.status = EXECUTION_STATUS.GENERATING_METADATA;
288
- try {
289
- const ttfAssetRef = fileInput.ttf?.asset?._ref || fileInput.otf?.asset?._ref;
290
- if (ttfAssetRef) {
291
- const metadata = await generateFontData({
292
- fileInput,
293
- fontKit: null, // Will re-parse from URL
294
- fontId: entry.documentId,
295
- client,
296
- commit: false, // Don't patch yet — we'll include in the document creation
297
- });
298
- Object.assign(entry, {
299
- metaData: metadata.metaData,
300
- metrics: metadata.metrics,
301
- variableAxes: metadata.variableAxes,
302
- variableInstances: metadata.variableInstances,
303
- opentypeFeatures: metadata.opentypeFeatures,
304
- characterSet: metadata.characterSet,
305
- glyphCount: metadata.glyphCount,
306
- variableFont: metadata.variableFont,
307
- });
308
-
309
- if (onProgress) {
310
- onProgress({ type: 'metadata-generated', tempId: entry.tempId, fontProgress: { ...fontProgress } });
311
- }
312
- }
313
- } catch (err) {
314
- console.warn('Metadata generation failed for', entry.title, ':', err.message);
315
- }
316
- }
317
-
318
- // Create or update font document
319
- fontProgress.status = EXECUTION_STATUS.CREATING_DOCUMENT;
320
-
321
- const fontDocId = shouldUpdate && existingDoc ? existingDoc._id : entry.documentId;
322
- const isNew = !shouldUpdate;
323
-
324
- const fontDoc = {
325
- _id: fontDocId,
326
- _type: 'font',
327
- _key: nanoid(),
328
- title: entry.title,
329
- slug: { _type: 'slug', current: fontDocId },
330
- typefaceName: plan.settings.typefaceTitle || entry.title,
331
- style: entry.style,
332
- variableFont: entry.variableFont,
333
- weightName: entry.weightName,
334
- subfamily: entry.subfamily,
335
- weight: entry.weight,
336
- price: plan.settings.price,
337
- sell: plan.settings.price > 0,
338
- normalWeight: true,
339
- fileInput,
340
- };
341
-
342
- // Add metadata fields if available
343
- if (entry.metaData) fontDoc.metaData = entry.metaData;
344
- if (entry.metrics) fontDoc.metrics = entry.metrics;
345
- if (entry.variableAxes) fontDoc.variableAxes = entry.variableAxes;
346
- if (entry.variableInstances) fontDoc.variableInstances = entry.variableInstances;
347
- if (entry.opentypeFeatures) fontDoc.opentypeFeatures = entry.opentypeFeatures;
348
- if (entry.characterSet) fontDoc.characterSet = entry.characterSet;
349
- if (entry.glyphCount) fontDoc.glyphCount = entry.glyphCount;
350
-
351
- try {
352
- if (shouldUpdate && existingDoc) {
353
- // Merge with existing data
354
- if (existingDoc.fileInput) {
355
- Object.keys(existingDoc.fileInput).forEach(key => {
356
- if (!fontDoc.fileInput[key]) fontDoc.fileInput[key] = existingDoc.fileInput[key];
357
- });
358
- }
359
- if (!fontDoc.metaData && existingDoc.metaData) fontDoc.metaData = existingDoc.metaData;
360
- if (!fontDoc.metrics && existingDoc.metrics) fontDoc.metrics = existingDoc.metrics;
361
- if (existingDoc.scriptFileInput) fontDoc.scriptFileInput = existingDoc.scriptFileInput;
362
- if (existingDoc.variableInstanceReferences) {
363
- fontDoc.variableInstanceReferences = existingDoc.variableInstanceReferences;
364
- }
365
-
366
- await client.patch(fontDocId).set(fontDoc).commit();
367
- console.log('Updated existing font:', fontDocId, entry.title);
368
- } else {
369
- await client.createOrReplace(fontDoc);
370
- console.log('Created new font:', fontDocId, entry.title);
371
- }
372
-
373
- fontProgress.status = EXECUTION_STATUS.COMPLETE;
374
-
375
- if (onProgress) {
376
- onProgress({ type: 'document-created', tempId: entry.tempId, isNew, fontProgress: { ...fontProgress } });
377
- }
378
-
379
- return {
380
- ref: {
381
- _key: nanoid(),
382
- _type: 'reference',
383
- _ref: fontDocId,
384
- _weak: true,
385
- },
386
- isNew,
387
- };
388
- } catch (err) {
389
- fontProgress.status = EXECUTION_STATUS.ERROR;
390
- fontProgress.error = err.message;
391
- throw new Error(`Document creation failed: ${err.message}`);
392
- }
393
- }
394
-
395
- /**
396
- * Determines the file type from a file's extension.
397
- * @param {File} file
398
- * @returns {string}
399
- */
400
- function determineFileType(file) {
401
- if (file.name.endsWith('.ttf')) return 'ttf';
402
- if (file.name.endsWith('.otf')) return 'otf';
403
- if (file.name.endsWith('.woff')) return 'woff';
404
- if (file.name.endsWith('.woff2')) return 'woff2';
405
- if (file.name.endsWith('.eot')) return 'eot';
406
- if (file.name.endsWith('.svg')) return 'svg';
407
- return '';
408
- }
409
-
410
- /**
411
- * Uploads with exponential backoff + jitter on 429 responses.
412
- * @param {function} uploadFn - Async function to call
413
- * @returns {Promise<object>}
414
- */
415
- async function uploadWithRetry(uploadFn) {
416
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
417
- try {
418
- return await uploadFn();
419
- } catch (err) {
420
- const is429 = err.statusCode === 429 || err.status === 429;
421
- if (is429 && attempt < MAX_RETRIES) {
422
- const delay = backoffWithJitter(attempt);
423
- console.warn(`Rate limited (429), retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
424
- await new Promise(resolve => setTimeout(resolve, delay));
425
- } else {
426
- throw err;
427
- }
428
- }
429
- }
430
- }
1
+ // Phase 2 executor — uploads assets, creates/updates font documents, patches the typeface document
2
+
3
+ import { nanoid } from 'nanoid';
4
+ import generateCssFile from './generateCssFile';
5
+ import generateFontData from './generateFontData';
6
+ import { parseVariableFontInstances } from './parseVariableFontInstances';
7
+ import { updateTypefaceDocument } from './updateTypefaceDocument';
8
+ import {
9
+ FONT_STATUS,
10
+ EXECUTION_STATUS,
11
+ RECOMMENDATION,
12
+ CONCURRENCY_LIMIT,
13
+ MAX_RETRIES,
14
+ backoffWithJitter,
15
+ } from './planTypes';
16
+
17
+ /**
18
+ * Phase 2: Executes a finalized plan — uploads assets, creates/updates
19
+ * font documents, patches the typeface document.
20
+ *
21
+ * Skips fonts with status 'error'. Caches asset references in progress
22
+ * for idempotent retry on partial failure.
23
+ *
24
+ * @param {object} params
25
+ * @param {object} params.plan - The reviewed and finalized UploadPlan
26
+ * @param {object} params.client - Sanity client
27
+ * @param {string} params.docId - Typeface document _id
28
+ * @param {object} params.stylesObject - Existing typeface styles
29
+ * @param {object} params.preferredStyleRef - Current preferredStyle reference
30
+ * @param {function} params.onProgress - Execution progress callback
31
+ * @returns {Promise<object>} ExecutionResult
32
+ */
33
+ export async function executeUploadPlan({
34
+ plan,
35
+ client,
36
+ docId,
37
+ stylesObject = {},
38
+ preferredStyleRef = {},
39
+ onProgress,
40
+ }) {
41
+ const result = {
42
+ success: true,
43
+ created: 0,
44
+ updated: 0,
45
+ failed: 0,
46
+ skipped: 0,
47
+ failedFonts: [],
48
+ fontRefs: [],
49
+ variableRefs: [],
50
+ typefacePatchError: null,
51
+ };
52
+
53
+ // Build execution queue — skip fonts with processing errors
54
+ const fontEntries = Object.values(plan.fonts);
55
+ const queue = fontEntries.filter(entry => entry.status !== FONT_STATUS.ERROR);
56
+ const skipped = fontEntries.filter(entry => entry.status === FONT_STATUS.ERROR);
57
+ result.skipped = skipped.length;
58
+
59
+ // Track per-font execution progress
60
+ const progress = {};
61
+ for (const entry of queue) {
62
+ progress[entry.tempId] = {
63
+ status: EXECUTION_STATUS.QUEUED,
64
+ currentFile: null,
65
+ filesComplete: 0,
66
+ filesTotal: entry.files.length,
67
+ assetRefs: {},
68
+ error: null,
69
+ };
70
+ }
71
+
72
+ if (onProgress) {
73
+ onProgress({ type: 'execution-start', totalFonts: queue.length, skippedFonts: result.skipped });
74
+ }
75
+
76
+ // Process fonts with concurrency limit
77
+ const chunks = [];
78
+ for (let i = 0; i < queue.length; i += CONCURRENCY_LIMIT) {
79
+ chunks.push(queue.slice(i, i + CONCURRENCY_LIMIT));
80
+ }
81
+
82
+ let newPreferredStyle = { weight: -100, style: 'Italic', _ref: '' };
83
+ const subfamilies = {};
84
+ const uniqueSubfamilies = new Set();
85
+
86
+ for (const chunk of chunks) {
87
+ const chunkResults = await Promise.allSettled(
88
+ chunk.map(entry => executeSingleFont({
89
+ entry,
90
+ plan,
91
+ client,
92
+ progress,
93
+ onProgress,
94
+ }))
95
+ );
96
+
97
+ for (let i = 0; i < chunkResults.length; i++) {
98
+ const chunkResult = chunkResults[i];
99
+ const entry = chunk[i];
100
+
101
+ if (chunkResult.status === 'fulfilled' && chunkResult.value) {
102
+ const fontResult = chunkResult.value;
103
+
104
+ if (fontResult.isNew) result.created++;
105
+ else result.updated++;
106
+
107
+ // Track for typeface patch
108
+ if (entry.variableFont) {
109
+ result.variableRefs.push(fontResult.ref);
110
+ } else {
111
+ result.fontRefs.push(fontResult.ref);
112
+ }
113
+
114
+ subfamilies[entry.documentId] = entry.subfamily;
115
+ if (entry.subfamily) uniqueSubfamilies.add(entry.subfamily);
116
+
117
+ // Track preferred style candidate
118
+ if (entry.weight > newPreferredStyle.weight) {
119
+ newPreferredStyle = {
120
+ weight: entry.weight,
121
+ style: entry.style,
122
+ _ref: fontResult.ref._ref,
123
+ };
124
+ }
125
+ } else {
126
+ result.failed++;
127
+ result.success = false;
128
+ const errorMsg = chunkResult.reason?.message || chunkResult.value?.error || 'Unknown error';
129
+ result.failedFonts.push({
130
+ tempId: entry.tempId,
131
+ title: entry.title,
132
+ error: errorMsg,
133
+ failedAt: progress[entry.tempId]?.status || 'unknown',
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ // Patch the typeface document with new font references
140
+ if (result.fontRefs.length > 0 || result.variableRefs.length > 0) {
141
+ try {
142
+ if (onProgress) {
143
+ onProgress({ type: 'typeface-patching' });
144
+ }
145
+
146
+ await updateTypefaceDocument(
147
+ docId,
148
+ result.fontRefs,
149
+ result.variableRefs,
150
+ subfamilies,
151
+ [...uniqueSubfamilies],
152
+ stylesObject?.subfamilies || [],
153
+ preferredStyleRef,
154
+ newPreferredStyle,
155
+ stylesObject,
156
+ client,
157
+ (msg) => { if (onProgress) onProgress({ type: 'typeface-status', message: msg }); },
158
+ (err) => { if (err) console.error('Typeface patch error flag set'); },
159
+ );
160
+
161
+ if (onProgress) {
162
+ onProgress({ type: 'typeface-patched' });
163
+ }
164
+ } catch (err) {
165
+ result.typefacePatchError = err.message;
166
+ result.success = false;
167
+ console.error('Typeface patch failed:', err.message);
168
+
169
+ if (onProgress) {
170
+ onProgress({ type: 'typeface-error', error: err.message });
171
+ }
172
+ }
173
+ }
174
+
175
+ if (onProgress) {
176
+ onProgress({ type: 'execution-complete', result });
177
+ }
178
+
179
+ return result;
180
+ }
181
+
182
+ /**
183
+ * Executes upload for a single font entry.
184
+ * @returns {Promise<{ ref: object, isNew: boolean }>}
185
+ */
186
+ async function executeSingleFont({ entry, plan, client, progress, onProgress }) {
187
+ const fontProgress = progress[entry.tempId];
188
+ fontProgress.status = EXECUTION_STATUS.UPLOADING_ASSETS;
189
+
190
+ if (onProgress) {
191
+ onProgress({ type: 'font-upload-start', tempId: entry.tempId, fontProgress: { ...fontProgress } });
192
+ }
193
+
194
+ // Determine action based on resolution
195
+ const decision = entry.decisions.existingDocument;
196
+ const userChoice = decision.userChoice;
197
+ const recommendation = decision.recommendation;
198
+ const shouldUpdate = userChoice === 'update' ||
199
+ (!userChoice && (recommendation === RECOMMENDATION.USE_EXACT || recommendation === RECOMMENDATION.USE_CANDIDATE));
200
+ const existingDoc = shouldUpdate
201
+ ? (decision.selectedCandidate || decision.exact || decision.candidates[0])
202
+ : null;
203
+
204
+ // Upload font files
205
+ const fileInput = {};
206
+ for (let j = 0; j < entry.files.length; j++) {
207
+ const file = entry.files[j];
208
+ const fileType = determineFileType(file);
209
+ if (!fileType) continue;
210
+
211
+ // Skip if already uploaded (idempotent retry)
212
+ if (fontProgress.assetRefs[fileType]) {
213
+ fileInput[fileType] = {
214
+ _type: 'file',
215
+ asset: { _type: 'reference', _ref: fontProgress.assetRefs[fileType] },
216
+ };
217
+ fontProgress.filesComplete++;
218
+ continue;
219
+ }
220
+
221
+ fontProgress.currentFile = fileType;
222
+
223
+ try {
224
+ const assetFilename = plan.settings.preserveFileNames && entry.originalFilename
225
+ ? `${entry.originalFilename}.${fileType}`
226
+ : `${entry.documentId}.${fileType}`;
227
+
228
+ const baseAsset = await uploadWithRetry(
229
+ () => client.assets.upload('file', file, { filename: assetFilename }),
230
+ );
231
+
232
+ // Override Sanity SHA1-dedup originalFilename if needed
233
+ if (plan.settings.preserveFileNames && baseAsset.originalFilename !== assetFilename) {
234
+ try {
235
+ await client.patch(baseAsset._id).set({ originalFilename: assetFilename }).commit();
236
+ } catch (renameErr) {
237
+ console.warn('Could not rename asset:', renameErr.message);
238
+ }
239
+ }
240
+
241
+ fileInput[fileType] = {
242
+ _type: 'file',
243
+ asset: { _type: 'reference', _ref: baseAsset._id },
244
+ };
245
+ fontProgress.assetRefs[fileType] = baseAsset._id;
246
+ fontProgress.filesComplete++;
247
+
248
+ if (onProgress) {
249
+ onProgress({ type: 'file-uploaded', tempId: entry.tempId, fileType, fontProgress: { ...fontProgress } });
250
+ }
251
+ } catch (err) {
252
+ fontProgress.status = EXECUTION_STATUS.ERROR;
253
+ fontProgress.error = err.message;
254
+ throw new Error(`Asset upload failed for ${fileType}: ${err.message}`);
255
+ }
256
+ }
257
+
258
+ // Generate CSS from WOFF2 if available
259
+ if (fileInput.woff2 || fileInput.woff) {
260
+ fontProgress.status = EXECUTION_STATUS.GENERATING_CSS;
261
+ try {
262
+ const woff2File = entry.files.find(f => f.name.endsWith('.woff2') || f.name.endsWith('.woff'));
263
+ if (woff2File) {
264
+ const updatedFileInput = await generateCssFile({
265
+ woff2File,
266
+ fileInput,
267
+ fileName: entry.documentId,
268
+ fontName: entry.title,
269
+ variableFont: entry.variableFont,
270
+ weight: entry.weight,
271
+ style: entry.style,
272
+ client,
273
+ });
274
+ Object.assign(fileInput, updatedFileInput);
275
+
276
+ if (onProgress) {
277
+ onProgress({ type: 'css-generated', tempId: entry.tempId, fontProgress: { ...fontProgress } });
278
+ }
279
+ }
280
+ } catch (err) {
281
+ console.warn('CSS generation failed for', entry.title, '— document created without CSS:', err.message);
282
+ }
283
+ }
284
+
285
+ // Generate font metadata
286
+ if (fileInput.ttf || fileInput.otf) {
287
+ fontProgress.status = EXECUTION_STATUS.GENERATING_METADATA;
288
+ try {
289
+ const ttfAssetRef = fileInput.ttf?.asset?._ref || fileInput.otf?.asset?._ref;
290
+ if (ttfAssetRef) {
291
+ const metadata = await generateFontData({
292
+ fileInput,
293
+ fontKit: null, // Will re-parse from URL
294
+ fontId: entry.documentId,
295
+ client,
296
+ commit: false, // Don't patch yet — we'll include in the document creation
297
+ });
298
+ Object.assign(entry, {
299
+ metaData: metadata.metaData,
300
+ metrics: metadata.metrics,
301
+ variableAxes: metadata.variableAxes,
302
+ variableInstances: metadata.variableInstances,
303
+ opentypeFeatures: metadata.opentypeFeatures,
304
+ characterSet: metadata.characterSet,
305
+ glyphCount: metadata.glyphCount,
306
+ variableFont: metadata.variableFont,
307
+ });
308
+
309
+ if (onProgress) {
310
+ onProgress({ type: 'metadata-generated', tempId: entry.tempId, fontProgress: { ...fontProgress } });
311
+ }
312
+ }
313
+ } catch (err) {
314
+ console.warn('Metadata generation failed for', entry.title, ':', err.message);
315
+ }
316
+ }
317
+
318
+ // Create or update font document
319
+ fontProgress.status = EXECUTION_STATUS.CREATING_DOCUMENT;
320
+
321
+ const fontDocId = shouldUpdate && existingDoc ? existingDoc._id : entry.documentId;
322
+ const isNew = !shouldUpdate;
323
+
324
+ const fontDoc = {
325
+ _id: fontDocId,
326
+ _type: 'font',
327
+ _key: nanoid(),
328
+ title: entry.title,
329
+ slug: { _type: 'slug', current: fontDocId },
330
+ typefaceName: plan.settings.typefaceTitle || entry.title,
331
+ style: entry.style,
332
+ variableFont: entry.variableFont,
333
+ weightName: entry.weightName,
334
+ subfamily: entry.subfamily,
335
+ weight: entry.weight,
336
+ price: plan.settings.price,
337
+ sell: plan.settings.price > 0,
338
+ normalWeight: true,
339
+ fileInput,
340
+ };
341
+
342
+ // Add metadata fields if available
343
+ if (entry.metaData) fontDoc.metaData = entry.metaData;
344
+ if (entry.metrics) fontDoc.metrics = entry.metrics;
345
+ if (entry.variableAxes) fontDoc.variableAxes = entry.variableAxes;
346
+ if (entry.variableInstances) fontDoc.variableInstances = entry.variableInstances;
347
+ if (entry.opentypeFeatures) fontDoc.opentypeFeatures = entry.opentypeFeatures;
348
+ if (entry.characterSet) fontDoc.characterSet = entry.characterSet;
349
+ if (entry.glyphCount) fontDoc.glyphCount = entry.glyphCount;
350
+
351
+ try {
352
+ if (shouldUpdate && existingDoc) {
353
+ // Merge with existing data
354
+ if (existingDoc.fileInput) {
355
+ Object.keys(existingDoc.fileInput).forEach(key => {
356
+ if (!fontDoc.fileInput[key]) fontDoc.fileInput[key] = existingDoc.fileInput[key];
357
+ });
358
+ }
359
+ if (!fontDoc.metaData && existingDoc.metaData) fontDoc.metaData = existingDoc.metaData;
360
+ if (!fontDoc.metrics && existingDoc.metrics) fontDoc.metrics = existingDoc.metrics;
361
+ if (existingDoc.scriptFileInput) fontDoc.scriptFileInput = existingDoc.scriptFileInput;
362
+ if (existingDoc.variableInstanceReferences) {
363
+ fontDoc.variableInstanceReferences = existingDoc.variableInstanceReferences;
364
+ }
365
+
366
+ await client.patch(fontDocId).set(fontDoc).commit();
367
+ console.log('Updated existing font:', fontDocId, entry.title);
368
+ } else {
369
+ await client.createOrReplace(fontDoc);
370
+ console.log('Created new font:', fontDocId, entry.title);
371
+ }
372
+
373
+ fontProgress.status = EXECUTION_STATUS.COMPLETE;
374
+
375
+ if (onProgress) {
376
+ onProgress({ type: 'document-created', tempId: entry.tempId, isNew, fontProgress: { ...fontProgress } });
377
+ }
378
+
379
+ return {
380
+ ref: {
381
+ _key: nanoid(),
382
+ _type: 'reference',
383
+ _ref: fontDocId,
384
+ _weak: true,
385
+ },
386
+ isNew,
387
+ };
388
+ } catch (err) {
389
+ fontProgress.status = EXECUTION_STATUS.ERROR;
390
+ fontProgress.error = err.message;
391
+ throw new Error(`Document creation failed: ${err.message}`);
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Determines the file type from a file's extension.
397
+ * @param {File} file
398
+ * @returns {string}
399
+ */
400
+ function determineFileType(file) {
401
+ if (file.name.endsWith('.ttf')) return 'ttf';
402
+ if (file.name.endsWith('.otf')) return 'otf';
403
+ if (file.name.endsWith('.woff')) return 'woff';
404
+ if (file.name.endsWith('.woff2')) return 'woff2';
405
+ if (file.name.endsWith('.eot')) return 'eot';
406
+ if (file.name.endsWith('.svg')) return 'svg';
407
+ return '';
408
+ }
409
+
410
+ /**
411
+ * Uploads with exponential backoff + jitter on 429 responses.
412
+ * @param {function} uploadFn - Async function to call
413
+ * @returns {Promise<object>}
414
+ */
415
+ async function uploadWithRetry(uploadFn) {
416
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
417
+ try {
418
+ return await uploadFn();
419
+ } catch (err) {
420
+ const is429 = err.statusCode === 429 || err.status === 429;
421
+ if (is429 && attempt < MAX_RETRIES) {
422
+ const delay = backoffWithJitter(attempt);
423
+ console.warn(`Rate limited (429), retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
424
+ await new Promise(resolve => setTimeout(resolve, delay));
425
+ } else {
426
+ throw err;
427
+ }
428
+ }
429
+ }
430
+ }