@liiift-studio/sanity-font-manager 2.4.0 → 2.5.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 (36) hide show
  1. package/dist/UploadModal-ADNRGQUI.mjs +6 -0
  2. package/dist/UploadModal-WPK2CXLR.js +6 -0
  3. package/dist/chunk-JCDZ7SWZ.js +7711 -0
  4. package/dist/chunk-TMDE4A54.mjs +7711 -0
  5. package/dist/index.js +666 -1647
  6. package/dist/index.mjs +319 -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 +455 -0
  12. package/src/components/SingleUploaderTool.jsx +3 -4
  13. package/src/components/UploadModal.jsx +304 -0
  14. package/src/components/UploadScriptsComponent.jsx +23 -21
  15. package/src/components/UploadStep1Settings.jsx +272 -0
  16. package/src/components/UploadStep2Review.jsx +474 -0
  17. package/src/components/UploadStep3Execute.jsx +234 -0
  18. package/src/components/UploadStep3bInstances.jsx +396 -0
  19. package/src/components/UploadSummary.jsx +196 -0
  20. package/src/index.js +46 -0
  21. package/src/utils/buildUploadPlan.js +326 -0
  22. package/src/utils/executeUploadPlan.js +430 -0
  23. package/src/utils/executionReducer.js +56 -0
  24. package/src/utils/fontHelpers.js +267 -0
  25. package/src/utils/generateCssFile.js +79 -77
  26. package/src/utils/generateFontData.js +47 -94
  27. package/src/utils/getEmptyFontKit.js +19 -17
  28. package/src/utils/parseFont.js +55 -0
  29. package/src/utils/parseVariableFontInstances.js +237 -147
  30. package/src/utils/planReducer.js +517 -0
  31. package/src/utils/planTypes.js +183 -0
  32. package/src/utils/processFontFiles.js +121 -78
  33. package/src/utils/regenerateFontData.js +2 -2
  34. package/src/utils/resolveExistingFont.js +87 -0
  35. package/src/utils/updateTypefaceDocument.js +15 -2
  36. package/src/utils/uploadFontFiles.js +405 -405
@@ -0,0 +1,196 @@
1
+ // Post-upload summary — results display with retry for failed fonts and typeface patch
2
+
3
+ import React, { useState, useCallback } from 'react';
4
+ import { Stack, Flex, Text, Card, Badge, Button, Box, Spinner } from '@sanity/ui';
5
+ import { CheckmarkCircleIcon, WarningOutlineIcon, ResetIcon } from '@sanity/icons';
6
+ import { updateTypefaceDocument } from '../utils/updateTypefaceDocument';
7
+
8
+ /**
9
+ * Post-upload summary — shows execution results with retry options.
10
+ */
11
+ export default function UploadSummary({
12
+ plan,
13
+ result,
14
+ onClose,
15
+ onRetry,
16
+ client,
17
+ docId,
18
+ stylesObject,
19
+ preferredStyleRef,
20
+ }) {
21
+ const [retryingPatch, setRetryingPatch] = useState(false);
22
+ const [patchRetryResult, setPatchRetryResult] = useState(null);
23
+
24
+ const hasFailedFonts = result?.failedFonts?.length > 0;
25
+ const hasTypefacePatchError = result?.typefacePatchError && !patchRetryResult?.success;
26
+ const allSuccess = result?.success && !hasTypefacePatchError;
27
+
28
+ /** Retry the typeface document patch only */
29
+ const handleRetryTypefacePatch = useCallback(async () => {
30
+ if (!result || !client || !docId) return;
31
+ setRetryingPatch(true);
32
+ setPatchRetryResult(null);
33
+
34
+ try {
35
+ const subfamilies = {};
36
+ const uniqueSubfamilies = new Set();
37
+ for (const entry of Object.values(plan.fonts)) {
38
+ if (entry.status === 'error') continue;
39
+ subfamilies[entry.documentId] = entry.subfamily;
40
+ if (entry.subfamily) uniqueSubfamilies.add(entry.subfamily);
41
+ }
42
+
43
+ await updateTypefaceDocument(
44
+ docId,
45
+ result.fontRefs || [],
46
+ result.variableRefs || [],
47
+ subfamilies,
48
+ [...uniqueSubfamilies],
49
+ stylesObject?.subfamilies || [],
50
+ preferredStyleRef || {},
51
+ { weight: -100, style: 'Italic', _ref: result.fontRefs?.[0]?._ref || '' },
52
+ stylesObject || {},
53
+ client,
54
+ () => {},
55
+ () => {},
56
+ );
57
+
58
+ setPatchRetryResult({ success: true });
59
+ } catch (err) {
60
+ console.error('Typeface patch retry failed:', err);
61
+ setPatchRetryResult({ success: false, error: err.message });
62
+ }
63
+
64
+ setRetryingPatch(false);
65
+ }, [result, client, docId, plan, stylesObject, preferredStyleRef]);
66
+
67
+ return (
68
+ <Stack space={4}>
69
+ {/* Header */}
70
+ <Flex align="center" gap={3} ref={(el) => el?.focus?.()} tabIndex={-1}>
71
+ {allSuccess ? (
72
+ <CheckmarkCircleIcon style={{ color: '#43b649', fontSize: 28 }} />
73
+ ) : (
74
+ <WarningOutlineIcon style={{ color: '#f03e2f', fontSize: 28 }} />
75
+ )}
76
+ <Text size={2} weight="semibold">
77
+ {allSuccess ? 'Upload Complete' : 'Upload Completed with Issues'}
78
+ </Text>
79
+ </Flex>
80
+
81
+ {/* Stats */}
82
+ {result && (
83
+ <Card border padding={4} radius={2}>
84
+ <Stack space={3}>
85
+ <Flex gap={2} wrap="wrap">
86
+ {result.created > 0 && (
87
+ <Badge tone="positive" fontSize={1}>
88
+ {result.created} created
89
+ </Badge>
90
+ )}
91
+ {result.updated > 0 && (
92
+ <Badge tone="caution" fontSize={1}>
93
+ {result.updated} updated
94
+ </Badge>
95
+ )}
96
+ {result.failed > 0 && (
97
+ <Badge tone="critical" fontSize={1}>
98
+ {result.failed} failed
99
+ </Badge>
100
+ )}
101
+ {result.skipped > 0 && (
102
+ <Badge mode="outline" fontSize={1}>
103
+ {result.skipped} skipped
104
+ </Badge>
105
+ )}
106
+ </Flex>
107
+ </Stack>
108
+ </Card>
109
+ )}
110
+
111
+ {/* Failed fonts */}
112
+ {hasFailedFonts && (
113
+ <Stack space={3}>
114
+ <Flex align="center" justify="space-between">
115
+ <Flex align="center" gap={2}>
116
+ <Text size={1} weight="semibold">Failed Fonts</Text>
117
+ <Badge tone="critical" fontSize={0}>{result.failedFonts.length}</Badge>
118
+ </Flex>
119
+ <Button
120
+ mode="ghost"
121
+ tone="primary"
122
+ icon={ResetIcon}
123
+ text="Retry Failed"
124
+ fontSize={1}
125
+ padding={2}
126
+ onClick={() => onRetry(result.failedFonts.map(f => f.tempId).filter(Boolean))}
127
+ />
128
+ </Flex>
129
+ <Stack space={2}>
130
+ {result.failedFonts.map((f, i) => (
131
+ <Card key={i} tone="critical" border padding={3} radius={2}>
132
+ <Stack space={2}>
133
+ <Flex align="center" gap={2}>
134
+ <Text size={1} weight="semibold">{f.title}</Text>
135
+ {f.failedAt && f.failedAt !== 'unknown' && (
136
+ <Badge tone="critical" fontSize={0} mode="outline">Failed at: {f.failedAt}</Badge>
137
+ )}
138
+ </Flex>
139
+ <Text size={1}>{f.error}</Text>
140
+ </Stack>
141
+ </Card>
142
+ ))}
143
+ </Stack>
144
+ </Stack>
145
+ )}
146
+
147
+ {/* Typeface patch error */}
148
+ {hasTypefacePatchError && (
149
+ <Card tone="caution" border padding={4} radius={2}>
150
+ <Stack space={3}>
151
+ <Text size={1} weight="semibold">Typeface Document Not Updated</Text>
152
+ <Text size={1}>
153
+ {result.created + result.updated} font document{result.created + result.updated === 1 ? '' : 's'} created/updated successfully, but the typeface document could not be patched to reference them.
154
+ </Text>
155
+ <Text size={1} muted>{result.typefacePatchError}</Text>
156
+ <Flex gap={2}>
157
+ <Button
158
+ mode="default"
159
+ tone="primary"
160
+ icon={retryingPatch ? undefined : ResetIcon}
161
+ text={retryingPatch ? 'Retrying...' : 'Retry Typeface Patch'}
162
+ disabled={retryingPatch}
163
+ onClick={handleRetryTypefacePatch}
164
+ />
165
+ {retryingPatch && <Spinner />}
166
+ </Flex>
167
+ </Stack>
168
+ </Card>
169
+ )}
170
+
171
+ {/* Successful typeface patch retry */}
172
+ {patchRetryResult?.success && (
173
+ <Card tone="positive" border padding={3} radius={2}>
174
+ <Text size={1}>Typeface document updated successfully on retry.</Text>
175
+ </Card>
176
+ )}
177
+
178
+ {/* Failed typeface patch retry */}
179
+ {patchRetryResult && !patchRetryResult.success && (
180
+ <Card tone="critical" border padding={3} radius={2}>
181
+ <Text size={1}>Retry failed: {patchRetryResult.error}</Text>
182
+ </Card>
183
+ )}
184
+
185
+ {/* Close */}
186
+ <Flex justify="flex-end">
187
+ <Button
188
+ mode="default"
189
+ tone="primary"
190
+ text="Close"
191
+ onClick={onClose}
192
+ />
193
+ </Flex>
194
+ </Stack>
195
+ );
196
+ }
package/src/index.js CHANGED
@@ -55,6 +55,52 @@ export { renameFontDocuments } from './utils/regenerateFontData.js';
55
55
  export { updateFontPrices } from './utils/updateFontPrices.js';
56
56
  export { sanitizeForSanityId } from './utils/sanitizeForSanityId.js';
57
57
 
58
+ // Plan/Execute split — v3.0 upload modal architecture
59
+ export { buildUploadPlan } from './utils/buildUploadPlan.js';
60
+ export { executeUploadPlan } from './utils/executeUploadPlan.js';
61
+ export { resolveExistingFont } from './utils/resolveExistingFont.js';
62
+ export {
63
+ FONT_STATUS,
64
+ PLAN_PHASE,
65
+ RECOMMENDATION,
66
+ EXECUTION_STATUS,
67
+ PLAN_VERSION,
68
+ createFontDecisions,
69
+ createEmptyPlan,
70
+ } from './utils/planTypes.js';
71
+ export { planReducer } from './utils/planReducer.js';
72
+ export { executionReducer, createInitialExecutionState } from './utils/executionReducer.js';
73
+
74
+ // Upload modal components
75
+ export { default as UploadModal } from './components/UploadModal.jsx';
76
+ export { default as UploadStep1Settings } from './components/UploadStep1Settings.jsx';
77
+ export { default as UploadStep2Review } from './components/UploadStep2Review.jsx';
78
+ export { default as UploadStep3Execute } from './components/UploadStep3Execute.jsx';
79
+ export { default as UploadStep3bInstances } from './components/UploadStep3bInstances.jsx';
80
+ export { default as UploadSummary } from './components/UploadSummary.jsx';
81
+ export { default as FontReviewCard } from './components/FontReviewCard.jsx';
82
+ export { default as ExistingDocumentResolver } from './components/ExistingDocumentResolver.jsx';
83
+ export { default as BulkActions } from './components/BulkActions.jsx';
84
+
85
+ // Font parsing (lib-font wrappers)
86
+ export { parseFont } from './utils/parseFont.js';
87
+ export {
88
+ getNameString,
89
+ getAllFeatureTags,
90
+ getCharacterSet,
91
+ getVariationAxes,
92
+ getNamedInstances,
93
+ getFontMetrics,
94
+ getFontMetadata,
95
+ getWeightClass,
96
+ getFsSelection,
97
+ getMacStyle,
98
+ getItalicAngle,
99
+ getGlyphCount,
100
+ getFamilyClass,
101
+ escapeCssFontName,
102
+ } from './utils/fontHelpers.js';
103
+
58
104
  // Schema field definitions
59
105
  export { openTypeField } from './schema/openTypeField.js';
60
106
  export { styleCountField } from './schema/styleCountField.js';
@@ -0,0 +1,326 @@
1
+ // Phase 1 orchestrator — parses font files, resolves existing documents, builds a complete upload plan for user review
2
+
3
+ import { nanoid } from 'nanoid';
4
+ import { parseFont } from './parseFont';
5
+ import { readFontFile, extractFontMetadata, determineWeight } from './processFontFiles';
6
+ import {
7
+ getNameString,
8
+ getFontMetadata,
9
+ getFontMetrics,
10
+ getVariationAxes,
11
+ getNamedInstances,
12
+ getAllFeatureTags,
13
+ getGlyphCount,
14
+ getCharacterSet,
15
+ getItalicAngle,
16
+ getWeightClass,
17
+ } from './fontHelpers';
18
+ import { resolveExistingFont } from './resolveExistingFont';
19
+ import { sanitizeForSanityId } from './sanitizeForSanityId';
20
+ import {
21
+ FONT_STATUS,
22
+ PLAN_PHASE,
23
+ RECOMMENDATION,
24
+ PLAN_VERSION,
25
+ createFontDecisions,
26
+ createEmptyPlan,
27
+ } from './planTypes';
28
+
29
+ /**
30
+ * Phase 1: Reads font files, parses metadata, resolves existing documents,
31
+ * returns a complete upload plan for user review.
32
+ *
33
+ * NOTE: This function performs Sanity READ operations (existing document lookups)
34
+ * but NO writes. The client parameter is used for reads only.
35
+ *
36
+ * @param {object} params
37
+ * @param {File[]} params.files - Font files to process
38
+ * @param {string} params.typefaceTitle - Parent typeface title
39
+ * @param {string} params.docId - Typeface document _id (for scoping resolution queries)
40
+ * @param {object} params.settings - { price, preserveShortenedNames, preserveFileNames }
41
+ * @param {object} params.client - Sanity client (read-only usage)
42
+ * @param {object} params.stylesObject - Existing typeface styles
43
+ * @param {string[]} params.weightKeywordList - Weight keywords for extraction
44
+ * @param {string[]} params.italicKeywordList - Italic keywords for extraction
45
+ * @param {function} params.onProgress - Called after each font is processed
46
+ * @returns {Promise<object>} UploadPlan object
47
+ */
48
+ export async function buildUploadPlan({
49
+ files,
50
+ typefaceTitle,
51
+ docId,
52
+ settings = {},
53
+ client,
54
+ stylesObject = {},
55
+ weightKeywordList = [],
56
+ italicKeywordList = [],
57
+ onProgress,
58
+ }) {
59
+ const plan = createEmptyPlan(settings);
60
+ plan.settings.typefaceTitle = typefaceTitle;
61
+ plan.phase = PLAN_PHASE.PROCESSING;
62
+ plan.processingProgress.total = files.length;
63
+
64
+ for (let i = 0; i < files.length; i++) {
65
+ const file = files[i];
66
+ plan.processingProgress.currentFile = file.name;
67
+
68
+ try {
69
+ const fontBuffer = await readFontFile(file);
70
+ const font = await parseFont(fontBuffer, file.name);
71
+
72
+ const entry = await buildFontPlanEntry({
73
+ file,
74
+ font,
75
+ typefaceTitle,
76
+ settings: plan.settings,
77
+ weightKeywordList,
78
+ italicKeywordList,
79
+ client,
80
+ });
81
+
82
+ // Merge into plan — if same documentId, merge files
83
+ const existingKey = Object.keys(plan.fonts).find(k => plan.fonts[k].documentId === entry.documentId);
84
+ if (existingKey) {
85
+ plan.fonts[existingKey].files = [...plan.fonts[existingKey].files, ...entry.files];
86
+ } else {
87
+ plan.fonts[entry.tempId] = entry;
88
+
89
+ // Add to subfamily group
90
+ if (!entry.variableFont || entry.subfamily) {
91
+ const sfName = entry.subfamily;
92
+ if (!plan.subfamilyGroups[sfName]) {
93
+ plan.subfamilyGroups[sfName] = { title: sfName, fontIds: [] };
94
+ }
95
+ plan.subfamilyGroups[sfName].fontIds.push(entry.tempId);
96
+ }
97
+ }
98
+
99
+ plan.processingProgress.completed++;
100
+
101
+ if (onProgress) {
102
+ onProgress({
103
+ type: 'font-processed',
104
+ tempId: entry.tempId,
105
+ fontEntry: entry,
106
+ progress: { ...plan.processingProgress },
107
+ });
108
+ }
109
+ } catch (err) {
110
+ console.error(`Error processing ${file.name}:`, err.message);
111
+ plan.processingProgress.failed++;
112
+
113
+ const errorTempId = sanitizeForSanityId(file.name) + '-' + nanoid(6);
114
+ plan.fonts[errorTempId] = {
115
+ tempId: errorTempId,
116
+ files: [file],
117
+ sourceFileName: file.name,
118
+ title: file.name,
119
+ documentId: '',
120
+ weight: 400,
121
+ weightName: '',
122
+ style: 'Regular',
123
+ subfamily: '',
124
+ variableFont: false,
125
+ originalFilename: null,
126
+ decisions: createFontDecisions({}),
127
+ status: FONT_STATUS.ERROR,
128
+ error: err.message,
129
+ parsedMetadata: null,
130
+ glyphCount: 0,
131
+ opentypeFeatures: [],
132
+ variationAxes: null,
133
+ };
134
+
135
+ if (onProgress) {
136
+ onProgress({
137
+ type: 'font-error',
138
+ tempId: errorTempId,
139
+ error: err.message,
140
+ progress: { ...plan.processingProgress },
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ plan.processingProgress.currentFile = null;
147
+ plan.phase = PLAN_PHASE.REVIEWING;
148
+
149
+ if (onProgress) {
150
+ onProgress({
151
+ type: 'processing-complete',
152
+ progress: { ...plan.processingProgress },
153
+ });
154
+ }
155
+
156
+ return plan;
157
+ }
158
+
159
+ /**
160
+ * Builds a single FontPlanEntry from a parsed font file.
161
+ * @param {object} params
162
+ * @returns {Promise<object>} FontPlanEntry
163
+ */
164
+ async function buildFontPlanEntry({
165
+ file,
166
+ font,
167
+ typefaceTitle,
168
+ settings,
169
+ weightKeywordList,
170
+ italicKeywordList,
171
+ client,
172
+ }) {
173
+ // Extract metadata using existing processFontFiles helpers
174
+ const { weightName, subfamilyName, fontTitle, style, italicKW, variableFont } = extractFontMetadata(
175
+ font,
176
+ typefaceTitle,
177
+ weightKeywordList,
178
+ italicKeywordList,
179
+ settings.preserveShortenedNames,
180
+ );
181
+
182
+ // Determine title and ID based on preserveFileNames setting
183
+ let finalTitle = fontTitle;
184
+ let originalFilename = null;
185
+
186
+ if (settings.preserveFileNames) {
187
+ originalFilename = file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, '');
188
+ const normalizedName = originalFilename
189
+ .replace(/-/g, ' ')
190
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
191
+ .replace(/\s+/g, ' ')
192
+ .trim();
193
+ finalTitle = normalizedName;
194
+ }
195
+
196
+ const documentId = sanitizeForSanityId(finalTitle);
197
+ const tempId = documentId + '-' + nanoid(6);
198
+ const weight = Number(determineWeight(font, weightName));
199
+
200
+ // Build title alternatives from all name sources
201
+ const titleAlternatives = [
202
+ { value: getNameString(font, 1), source: 'nameId1-familyName' },
203
+ { value: getNameString(font, 4), source: 'nameId4-fullName' },
204
+ { value: getNameString(font, 6), source: 'nameId6-postscriptName' },
205
+ { value: getNameString(font, 16), source: 'nameId16-preferredFamily' },
206
+ { value: getNameString(font, 17), source: 'nameId17-preferredSubfamily' },
207
+ { value: file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, ''), source: 'filename' },
208
+ ].filter(alt => alt.value);
209
+
210
+ // Determine title source
211
+ const titleSource = settings.preserveFileNames ? 'filename' : 'fontkit-fullName';
212
+
213
+ // Determine weight source
214
+ const usWeightClass = getWeightClass(font);
215
+ const weightSource = usWeightClass ? 'os2-usWeightClass' : 'keyword-match';
216
+ const matchedKeyword = usWeightClass ? null : weightName;
217
+
218
+ // Determine weight name source — nameId17 (preferredSubfamily) or nameId2 (fontSubfamily)
219
+ const nameId17 = getNameString(font, 17);
220
+ const weightNameSource = variableFont
221
+ ? 'variable-font-empty'
222
+ : nameId17
223
+ ? 'nameId17-preferredSubfamily'
224
+ : 'nameId2-fontSubfamily';
225
+
226
+ // Determine style source
227
+ const italicAngle = getItalicAngle(font);
228
+ const fullName = getNameString(font, 4);
229
+ let styleSource = 'default-regular';
230
+ let styleReason = '';
231
+ if (style === 'Italic') {
232
+ if (italicAngle !== 0) {
233
+ styleSource = 'italic-angle';
234
+ styleReason = `italicAngle = ${italicAngle}`;
235
+ } else if (fullName.toLowerCase().includes('italic')) {
236
+ styleSource = 'name-contains-italic';
237
+ styleReason = 'fullName contains "italic"';
238
+ }
239
+ }
240
+
241
+ // Determine subfamily source — mirrors the logic in extractFontMetadata
242
+ const familyNameRaw = getNameString(font, 1);
243
+ const nameId4Remainder = fullName ? fullName.replace(typefaceTitle.trim(), '').trim() : '';
244
+ const nameId1Remainder = familyNameRaw ? familyNameRaw.replace(typefaceTitle.trim(), '').trim() : '';
245
+ let subfamilySource = 'default-empty';
246
+ if (nameId4Remainder && subfamilyName) {
247
+ subfamilySource = 'nameId4-subtraction';
248
+ } else if (nameId1Remainder && subfamilyName) {
249
+ subfamilySource = 'nameId1-subtraction';
250
+ }
251
+
252
+ // Build decisions audit trail
253
+ const decisions = createFontDecisions({
254
+ titleSource,
255
+ title: finalTitle,
256
+ titleOriginal: getNameString(font, 4),
257
+ documentId,
258
+ weight,
259
+ weightSource,
260
+ matchedKeyword,
261
+ weightName,
262
+ weightNameSource,
263
+ style,
264
+ styleSource,
265
+ styleReason,
266
+ subfamily: subfamilyName,
267
+ subfamilySource,
268
+ titleAlternatives,
269
+ });
270
+
271
+ // Resolve existing document
272
+ const fontForResolution = {
273
+ _id: documentId,
274
+ typefaceName: typefaceTitle,
275
+ weightName,
276
+ style,
277
+ subfamily: subfamilyName,
278
+ variableFont,
279
+ title: finalTitle,
280
+ };
281
+
282
+ try {
283
+ const resolution = await resolveExistingFont(fontForResolution, client);
284
+ decisions.existingDocument = {
285
+ recommendation: resolution.recommendation,
286
+ exact: resolution.exact,
287
+ candidates: resolution.candidates,
288
+ userChoice: null,
289
+ selectedCandidate: null,
290
+ lookupFailed: resolution.lookupFailed || false,
291
+ };
292
+ } catch (err) {
293
+ console.warn('Document resolution failed for', documentId, err.message);
294
+ decisions.existingDocument.lookupFailed = true;
295
+ }
296
+
297
+ // Extract parsed metadata snapshot (no raw fontkit/lib-font object retained)
298
+ const metadata = getFontMetadata(font);
299
+ const metrics = getFontMetrics(font);
300
+ const axes = getVariationAxes(font);
301
+
302
+ // Default empty subfamily to "Regular" — matches Regenerate Subfamilies behavior
303
+ const finalSubfamily = variableFont ? '' : (subfamilyName || 'Regular');
304
+ console.log(`[buildFontPlanEntry] "${finalTitle}" → subfamily: "${subfamilyName}" → final: "${finalSubfamily}" (variableFont: ${variableFont})`);
305
+
306
+ return {
307
+ tempId,
308
+ files: [file],
309
+ sourceFileName: file.name,
310
+ title: finalTitle,
311
+ documentId,
312
+ weight,
313
+ weightName,
314
+ style,
315
+ subfamily: finalSubfamily,
316
+ variableFont,
317
+ originalFilename,
318
+ decisions,
319
+ status: FONT_STATUS.PROCESSED,
320
+ error: null,
321
+ parsedMetadata: { ...metadata, ...metrics },
322
+ glyphCount: getGlyphCount(font),
323
+ opentypeFeatures: getAllFeatureTags(font),
324
+ variationAxes: axes,
325
+ };
326
+ }