@liiift-studio/sanity-font-manager 2.4.0 → 2.5.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 (33) hide show
  1. package/dist/UploadModal-6LIX7XOK.js +6 -0
  2. package/dist/UploadModal-NME2W53V.mjs +6 -0
  3. package/dist/chunk-646WCBRR.mjs +7276 -0
  4. package/dist/chunk-FH4QKHOH.js +7276 -0
  5. package/dist/index.js +664 -1647
  6. package/dist/index.mjs +317 -1209
  7. package/package.json +5 -5
  8. package/src/components/BatchUploadFonts.jsx +57 -44
  9. package/src/components/BulkActions.jsx +99 -0
  10. package/src/components/ExistingDocumentResolver.jsx +152 -0
  11. package/src/components/FontReviewCard.jsx +415 -0
  12. package/src/components/SingleUploaderTool.jsx +3 -4
  13. package/src/components/UploadModal.jsx +268 -0
  14. package/src/components/UploadScriptsComponent.jsx +23 -21
  15. package/src/components/UploadStep1Settings.jsx +272 -0
  16. package/src/components/UploadStep2Review.jsx +472 -0
  17. package/src/components/UploadStep3Execute.jsx +234 -0
  18. package/src/components/UploadSummary.jsx +196 -0
  19. package/src/index.js +45 -0
  20. package/src/utils/buildUploadPlan.js +325 -0
  21. package/src/utils/executeUploadPlan.js +437 -0
  22. package/src/utils/executionReducer.js +56 -0
  23. package/src/utils/fontHelpers.js +267 -0
  24. package/src/utils/generateCssFile.js +79 -77
  25. package/src/utils/generateFontData.js +47 -94
  26. package/src/utils/getEmptyFontKit.js +19 -17
  27. package/src/utils/parseFont.js +55 -0
  28. package/src/utils/planReducer.js +517 -0
  29. package/src/utils/planTypes.js +183 -0
  30. package/src/utils/processFontFiles.js +120 -78
  31. package/src/utils/regenerateFontData.js +2 -2
  32. package/src/utils/resolveExistingFont.js +87 -0
  33. package/src/utils/uploadFontFiles.js +405 -405
@@ -0,0 +1,437 @@
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.fonts[entry.tempId]?.decisions?.title?.original
331
+ ? entry.title.split(' ').slice(0, -1).join(' ') || entry.title
332
+ : entry.title,
333
+ style: entry.style,
334
+ variableFont: entry.variableFont,
335
+ weightName: entry.weightName,
336
+ subfamily: entry.subfamily,
337
+ weight: entry.weight,
338
+ price: plan.settings.price,
339
+ sell: plan.settings.price > 0,
340
+ normalWeight: true,
341
+ fileInput,
342
+ };
343
+
344
+ // Set typefaceName from the typeface title, not derived from font title
345
+ fontDoc.typefaceName = Object.values(plan.fonts)[0]?.decisions?.title?.original
346
+ ? plan.settings.typefaceTitle || fontDoc.typefaceName
347
+ : fontDoc.typefaceName;
348
+
349
+ // Add metadata fields if available
350
+ if (entry.metaData) fontDoc.metaData = entry.metaData;
351
+ if (entry.metrics) fontDoc.metrics = entry.metrics;
352
+ if (entry.variableAxes) fontDoc.variableAxes = entry.variableAxes;
353
+ if (entry.variableInstances) fontDoc.variableInstances = entry.variableInstances;
354
+ if (entry.opentypeFeatures) fontDoc.opentypeFeatures = entry.opentypeFeatures;
355
+ if (entry.characterSet) fontDoc.characterSet = entry.characterSet;
356
+ if (entry.glyphCount) fontDoc.glyphCount = entry.glyphCount;
357
+
358
+ try {
359
+ if (shouldUpdate && existingDoc) {
360
+ // Merge with existing data
361
+ if (existingDoc.fileInput) {
362
+ Object.keys(existingDoc.fileInput).forEach(key => {
363
+ if (!fontDoc.fileInput[key]) fontDoc.fileInput[key] = existingDoc.fileInput[key];
364
+ });
365
+ }
366
+ if (!fontDoc.metaData && existingDoc.metaData) fontDoc.metaData = existingDoc.metaData;
367
+ if (!fontDoc.metrics && existingDoc.metrics) fontDoc.metrics = existingDoc.metrics;
368
+ if (existingDoc.scriptFileInput) fontDoc.scriptFileInput = existingDoc.scriptFileInput;
369
+ if (existingDoc.variableInstanceReferences) {
370
+ fontDoc.variableInstanceReferences = existingDoc.variableInstanceReferences;
371
+ }
372
+
373
+ await client.patch(fontDocId).set(fontDoc).commit();
374
+ console.log('Updated existing font:', fontDocId, entry.title);
375
+ } else {
376
+ await client.createOrReplace(fontDoc);
377
+ console.log('Created new font:', fontDocId, entry.title);
378
+ }
379
+
380
+ fontProgress.status = EXECUTION_STATUS.COMPLETE;
381
+
382
+ if (onProgress) {
383
+ onProgress({ type: 'document-created', tempId: entry.tempId, isNew, fontProgress: { ...fontProgress } });
384
+ }
385
+
386
+ return {
387
+ ref: {
388
+ _key: nanoid(),
389
+ _type: 'reference',
390
+ _ref: fontDocId,
391
+ _weak: true,
392
+ },
393
+ isNew,
394
+ };
395
+ } catch (err) {
396
+ fontProgress.status = EXECUTION_STATUS.ERROR;
397
+ fontProgress.error = err.message;
398
+ throw new Error(`Document creation failed: ${err.message}`);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Determines the file type from a file's extension.
404
+ * @param {File} file
405
+ * @returns {string}
406
+ */
407
+ function determineFileType(file) {
408
+ if (file.name.endsWith('.ttf')) return 'ttf';
409
+ if (file.name.endsWith('.otf')) return 'otf';
410
+ if (file.name.endsWith('.woff')) return 'woff';
411
+ if (file.name.endsWith('.woff2')) return 'woff2';
412
+ if (file.name.endsWith('.eot')) return 'eot';
413
+ if (file.name.endsWith('.svg')) return 'svg';
414
+ return '';
415
+ }
416
+
417
+ /**
418
+ * Uploads with exponential backoff + jitter on 429 responses.
419
+ * @param {function} uploadFn - Async function to call
420
+ * @returns {Promise<object>}
421
+ */
422
+ async function uploadWithRetry(uploadFn) {
423
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
424
+ try {
425
+ return await uploadFn();
426
+ } catch (err) {
427
+ const is429 = err.statusCode === 429 || err.status === 429;
428
+ if (is429 && attempt < MAX_RETRIES) {
429
+ const delay = backoffWithJitter(attempt);
430
+ console.warn(`Rate limited (429), retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
431
+ await new Promise(resolve => setTimeout(resolve, delay));
432
+ } else {
433
+ throw err;
434
+ }
435
+ }
436
+ }
437
+ }
@@ -0,0 +1,56 @@
1
+ // Execution reducer — manages ExecutionState separately from the plan to prevent progress ticks from re-rendering the review UI
2
+
3
+ import { EXECUTION_STATUS } from './planTypes';
4
+
5
+ /**
6
+ * Creates the initial execution state.
7
+ * @returns {object}
8
+ */
9
+ export function createInitialExecutionState() {
10
+ return {
11
+ status: 'idle',
12
+ progress: {},
13
+ error: null,
14
+ };
15
+ }
16
+
17
+ /**
18
+ * Execution reducer for useReducer. Manages per-font upload progress.
19
+ * Mounted in UploadStep3Execute — isolated from the plan/review UI.
20
+ *
21
+ * @param {object} state - Current ExecutionState
22
+ * @param {object} action - Dispatched action
23
+ * @returns {object} New ExecutionState
24
+ */
25
+ export function executionReducer(state, action) {
26
+ switch (action.type) {
27
+ case 'SET_EXECUTION_STATUS': {
28
+ return { ...state, status: action.status };
29
+ }
30
+
31
+ case 'SET_FONT_EXECUTION_PROGRESS': {
32
+ const { tempId, progress } = action;
33
+ return {
34
+ ...state,
35
+ progress: {
36
+ ...state.progress,
37
+ [tempId]: {
38
+ ...(state.progress[tempId] || {}),
39
+ ...progress,
40
+ },
41
+ },
42
+ };
43
+ }
44
+
45
+ case 'SET_EXECUTION_ERROR': {
46
+ return {
47
+ ...state,
48
+ status: 'error',
49
+ error: action.error,
50
+ };
51
+ }
52
+
53
+ default:
54
+ return state;
55
+ }
56
+ }