@liiift-studio/sanity-font-manager 2.6.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 +1628 -17701
  4. package/dist/index.mjs +1537 -17599
  5. package/package.json +83 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -653
  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 -304
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -474
  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 -267
  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 -517
  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 -0
  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
package/README.md CHANGED
@@ -1,437 +1,695 @@
1
- # @liiift-studio/sanity-font-manager
2
-
3
- Full font management suite for Sanity Studio. Handles batch upload, multi-format conversion, metadata extraction, CSS `@font-face` generation, collection and pair generation, and script variant management.
4
-
5
- Compatible with Sanity v3, v4, and v5.
6
-
7
- ---
8
-
9
- ## Installation
10
-
11
- ```bash
12
- npm install @liiift-studio/sanity-font-manager
13
- ```
14
-
15
- ### Peer dependencies
16
-
17
- ```bash
18
- npm install sanity @sanity/ui @sanity/icons react
19
- ```
20
-
21
- | Peer | Required version |
22
- |---|---|
23
- | `sanity` | `>=3` |
24
- | `@sanity/ui` | `>=3` |
25
- | `@sanity/icons` | `>=3` |
26
- | `react` | `>=18` |
27
-
28
- If you hit peer dependency conflicts, add `legacy-peer-deps=true` to your `.npmrc`.
29
-
30
- ---
31
-
32
- ## Components
33
-
34
- ### `BatchUploadFonts`
35
-
36
- Drag-and-drop batch uploader for a typeface document. Accepts TTF/OTF/WOFF/WOFF2 etc., shows a reviewable file list with count, confirm button, elapsed timer, Wake Lock, and `beforeunload` guard for long uploads. Calls `uploadFontFiles` for each batch.
37
-
38
- ```jsx
39
- import { BatchUploadFonts } from '@liiift-studio/sanity-font-manager';
40
-
41
- export const typefaceSchema = {
42
- name: 'typeface',
43
- type: 'document',
44
- fields: [
45
- {
46
- name: 'styles',
47
- type: 'object',
48
- components: { input: BatchUploadFonts },
49
- fields: [ /* see Schema fields below */ ],
50
- },
51
- ],
52
- };
53
- ```
54
-
55
- ### `SingleUploaderTool`
56
-
57
- Per-font file manager inside a font document. Shows TTF/OTF/WOFF/WOFF2/CSS rows always. EOT/SVG/WEB/SUBSET/DATA are hidden behind an advanced toggle (cog icon). Each row has Upload/Build/Delete controls. Handles CSS regeneration, font data extraction, and WEB+SUBSET building via fontWorker.
58
-
59
- ```jsx
60
- import { SingleUploaderTool } from '@liiift-studio/sanity-font-manager';
61
-
62
- {
63
- name: 'fileInput',
64
- type: 'object',
65
- components: { input: SingleUploaderTool },
66
- fields: [ /* format fields — see Schema fields below */ ],
67
- }
68
- ```
69
-
70
- ### `GenerateCollectionsPairsComponent`
71
-
72
- One-click generator for Full Family, Uprights, Italics, and Subfamily collections, plus Regular/Italic pairs matched by weight. Has configurable price inputs for collection-per-font and pair price.
73
-
74
- ```jsx
75
- import { GenerateCollectionsPairsComponent } from '@liiift-studio/sanity-font-manager';
76
- ```
77
-
78
- ### `PrimaryCollectionGeneratorTypeface`
79
-
80
- One-click generator for a single full-family collection that includes all fonts linked to the typeface. Prepends the new collection to the existing `styles.collections` array — non-destructive. Uses `SANITY_STUDIO_DEFAULT_COLLECTION_PRICE` as the default price, falling back to `100`.
81
-
82
- Wire it up on a `string` field in the typeface schema:
83
-
84
- ```jsx
85
- import { PrimaryCollectionGeneratorTypeface } from '@liiift-studio/sanity-font-manager';
86
-
87
- {
88
- name: 'generateCollectionGroup',
89
- type: 'string',
90
- title: 'Generate Full Family Collection',
91
- description: 'Generate a collection that includes all the styles from this typeface.',
92
- components: { input: PrimaryCollectionGeneratorTypeface },
93
- hidden: ({ parent }) => !parent?.styles?.fonts?.length,
94
- }
95
- ```
96
-
97
- ### `FontScriptUploaderComponent`
98
-
99
- Script-aware uploader for per-script font file variants (Latin, Arabic, Hebrew, etc.) stored in `scriptFileInput` on the font document.
100
-
101
- ### `UploadScriptsComponent`
102
-
103
- Batch uploader for script-specific font variants across multiple fonts at once.
104
-
105
- ### `UpdateScriptsComponent`
106
-
107
- Updates and re-links existing script font variant references on font documents — used to fix or reassign script variant assignments.
108
-
109
- ### `RegenerateSubfamiliesComponent`
110
-
111
- Recalculates and patches the `subfamily` field on all fonts linked to a typeface, based on the typeface's defined subfamily groups — without re-uploading any files.
112
-
113
- ### `SetOTF`
114
-
115
- Detects which configured OpenType feature keys are supported by the typeface's first linked font. Reads `opentypeFeatures.chars` from the font document (populated by `generateFontData`) and patches the `features` array on the field. Shows a feature count when features are detected, and clear error messages when font data is missing.
116
-
117
- Wire it up on the `openType` object field in the typeface schema:
118
-
119
- ```jsx
120
- import { SetOTF } from '@liiift-studio/sanity-font-manager';
121
-
122
- {
123
- name: 'openType',
124
- type: 'object',
125
- components: { input: SetOTF },
126
- options: { collapsible: true },
127
- fields: [ /* feature fields each with a `feature` string e.g. 'liga', 'smcp' */ ],
128
- }
129
- ```
130
-
131
- ### `StyleCountInput`
132
-
133
- Displays the total number of font styles (static + variable) linked to a typeface. Reads `styles.fonts` and `styles.variableFont` arrays from the form context. Useful as a read-only display field in the typeface schema.
134
-
135
- ```jsx
136
- import { StyleCountInput } from '@liiift-studio/sanity-font-manager';
137
-
138
- {
139
- name: 'styleCount',
140
- type: 'number',
141
- readOnly: true,
142
- components: { input: StyleCountInput },
143
- }
144
- ```
145
-
146
- ### `KeyValueInput`
147
-
148
- Generic ordered key-value editor where both keys and values are plain strings. Supports add, remove, and reorder (up/down arrows). Values are stored as an array of `{ key, value }` objects.
149
-
150
- ```jsx
151
- import { KeyValueInput } from '@liiift-studio/sanity-font-manager';
152
-
153
- {
154
- name: 'aliases',
155
- type: 'array',
156
- of: [{ type: 'object', fields: [{ name: 'key', type: 'string' }, { name: 'value', type: 'string' }] }],
157
- components: { input: KeyValueInput },
158
- }
159
- ```
160
-
161
- ### `KeyValueReferenceInput`
162
-
163
- Generic key-value editor where keys are plain strings and values are weak Sanity document references. Supports searching by title via a popover picker, add/remove/reorder, and an optional `topActions` slot for action buttons above the list.
164
-
165
- | Prop | Type | Description |
166
- |---|---|---|
167
- | `fetchReferences` | `async (client, doc) => [{_id, title}]` | Async function that returns candidate references for the picker. Receives the Sanity client and the current document. |
168
- | `topActions` | `ReactNode` | Optional content rendered above the key-value rows (e.g. autofill buttons). |
169
- | `referenceType` | `string` | Document type for the created weak references (default: `'font'`). |
170
-
171
- ```jsx
172
- import { KeyValueReferenceInput } from '@liiift-studio/sanity-font-manager';
173
-
174
- {
175
- name: 'instanceMap',
176
- type: 'array',
177
- of: [{ type: 'object', fields: [{ name: 'key', type: 'string' }, { name: 'value', type: 'reference', weak: true, to: [{ type: 'font' }] }] }],
178
- components: { input: KeyValueReferenceInput },
179
- // Pass props via options or a wrapper component:
180
- options: {
181
- fetchReferences: async (client, doc) => client.fetch('*[_type == "font"]{_id, title}'),
182
- referenceType: 'font',
183
- },
184
- }
185
- ```
186
-
187
- ### `VariableInstanceReferencesInput`
188
-
189
- Font-specific wrapper around `KeyValueReferenceInput` for mapping variable font instance names to their matching static font documents. Provides:
190
-
191
- - A picker filtered to fonts sharing the same `typefaceName`, excluding variable fonts
192
- - **Autofill with Matching** — calls `parseVariableFontInstances` to match instance names to existing font documents by weight/style heuristics
193
- - **Autofill Keys Only** — populates instance name keys from the font's `variableInstances` metadata without resolving references
194
- - Autofill buttons are shown only when the document is a variable font with parsed instance data
195
- - Replace/merge confirmation dialog when pairs already exist
196
-
197
- ```jsx
198
- import { VariableInstanceReferencesInput } from '@liiift-studio/sanity-font-manager';
199
-
200
- {
201
- name: 'variableInstanceReferences',
202
- title: 'Variable Font Instances',
203
- type: 'array',
204
- hidden: ({ parent }) => !parent.variableFont,
205
- of: [
206
- {
207
- type: 'object',
208
- fields: [
209
- { name: 'key', type: 'string', title: 'Instance Name' },
210
- { name: 'value', type: 'reference', weak: true, to: [{ type: 'font' }], title: 'Matching Font' },
211
- ],
212
- },
213
- ],
214
- components: { input: VariableInstanceReferencesInput },
215
- }
216
- ```
217
-
218
- ### `StatusDisplay`
219
-
220
- Shared status bar used by all components. Shows `Status: [message]` in green on success and red on error, with an optional `action` element slot on the far right (used for the advanced toggle in `SingleUploaderTool`).
221
-
222
- ```jsx
223
- import { StatusDisplay } from '@liiift-studio/sanity-font-manager';
224
-
225
- <StatusDisplay status="ready" error={false} action={<Button ... />} />
226
- ```
227
-
228
- ### `PriceInput`
229
-
230
- Reusable `$` + number input for collection and pair price fields.
231
-
232
- ### `UploadButton`
233
-
234
- Label-wrapped button that triggers a hidden file input.
235
-
236
- ---
237
-
238
- ## Schema field definitions
239
-
240
- Pre-built Sanity schema field objects that can be spread directly into a typeface schema's `fields` array. Eliminates hundreds of lines of repeated field definitions across consumer studios.
241
-
242
- ### `openTypeField`
243
-
244
- A complete `openType` object field wired to the `openType` tab group. Includes the `features` checkbox array (all standard OpenType feature keys) plus per-feature sub-objects with `title`, `feature`, and `customText` fields. Uses `SetOTF` internally for auto-detection.
245
-
246
- ```js
247
- import { openTypeField } from '@liiift-studio/sanity-font-manager';
248
-
249
- // In your typeface schema fields array:
250
- openTypeField,
251
- ```
252
-
253
- Requires the `openType` group to be declared in your schema's `groups` array:
254
- ```js
255
- { name: 'openType', title: 'Open Type' }
256
- ```
257
-
258
- ### `styleCountField`
259
-
260
- A read-only `number` field in the `styles` group that displays the total count of static + variable font styles linked to the typeface. Uses `StyleCountInput` internally.
261
-
262
- ```js
263
- import { styleCountField } from '@liiift-studio/sanity-font-manager';
264
-
265
- // In your typeface schema fields array:
266
- styleCountField,
267
- ```
268
-
269
- ### `stylisticSetField`
270
-
271
- A complete `stylisticSet` object field for the `stylisticSets` group. Contains two sub-arrays: `featured` (highlighted words/phrases with per-character backtick syntax, stylistic feature picker, size, and CSS overrides) and `sets` (full catalogue of feature → glyph mappings). Both include the full OpenType feature dropdown (44 named features + all 20 stylistic sets).
272
-
273
- ```js
274
- import { stylisticSetField } from '@liiift-studio/sanity-font-manager';
275
-
276
- // In your typeface schema fields array:
277
- stylisticSetField,
278
- ```
279
-
280
- Requires the `stylisticSets` group to be declared in your schema's `groups` array:
281
- ```js
282
- { name: 'stylisticSets', title: 'Stylistic Sets' }
283
- ```
284
-
285
- ---
286
-
287
- ## Hook
288
-
289
- ### `useSanityClient`
290
-
291
- Returns the Sanity client instance from the studio context. Used internally by all components.
292
-
293
- ```js
294
- import { useSanityClient } from '@liiift-studio/sanity-font-manager';
295
-
296
- const client = useSanityClient();
297
- ```
298
-
299
- ---
300
-
301
- ## Utilities
302
-
303
- ### Font processing
304
-
305
- | Export | Description |
306
- |---|---|
307
- | `processFontFiles` | Reads font files via FileReader, parses with fontkit, and builds the `fontsObjects` map used by `uploadFontFiles` |
308
- | `extractFontMetadata` | Extracts weight name, subfamily, style, and variable font flag from a fontkit instance |
309
- | `extractWeightName` | Reads the weight name from fontkit name records, falling back through `preferredSubfamily fontSubfamily` |
310
- | `extractWeightFromFullName` | Strips the typeface title from the font's full name to isolate the weight/style suffix |
311
- | `processSubfamilyName` | Strips weight and italic keywords from a subfamily string, preserving non-style words like "Condensed" |
312
- | `formatFontTitle` | Normalises a font filename into a human-readable title — expands abbreviations, title-cases, collapses spaces |
313
- | `addItalicToFontTitle` | Appends the detected italic keyword to a title when the font has a non-zero italic angle |
314
- | `determineWeight` | Maps a weight name to a CSS numeric weight, preferring OS/2 `usWeightClass` when available |
315
- | `sortFontObjects` | Sorts a `fontsObjects` map by ascending weight, placing Regular before Italic at equal weights |
316
- | `createFontObject` | Builds the full font object (id, title, weight, style, files, etc.) for a single font file |
317
- | `uploadFontFiles` | Core batch upload orchestrator uploads each format to Sanity, generates CSS and metadata, then creates or updates font documents |
318
- | `updateTypefaceDocument` | Patches the parent typeface document's `styles.fonts` array with newly uploaded font references |
319
- | `renameFontDocuments` | Renames font document IDs across a typeface when a typeface slug changes |
320
- | `updateFontPrices` | Bulk-updates the `price` field across all font documents linked to a typeface |
321
- | `sanitizeForSanityId` | Converts arbitrary strings into valid Sanity document IDs (lowercase, hyphens, no special characters) |
322
-
323
- ### CSS and file generation
324
-
325
- | Export | Description |
326
- |---|---|
327
- | `generateCssFile` | Builds a `@font-face` CSS file from a WOFF2 blob — URL or base64 `src`, variable font axis descriptors, and metric-tuned fallback `@font-face` for CLS reduction |
328
- | `buildVFDescriptors` | Pure function maps fontkit variation axes to CSS descriptors (`font-weight`, `font-stretch`, `font-style`), handling degenerate axes, `slnt`/`ital` priority, and `min > max` clamping |
329
- | `generateFontData` | Fetches a TTF URL, parses with fontkit, and patches the Sanity font document with `metaData`, `metrics`, `glyphCount`, `opentypeFeatures`, `characterSet`, and variable axes/instances |
330
- | `buildFontMetadata` | Pure function — extracts `metaData` and `metrics` from a fontkit instance without any Sanity side effects |
331
- | `generateFontFile` | Fires a POST to the consuming site's `/api/sanity/fontWorker` endpoint with the format codes to convert (otf, woff, woff2, eot, svg, data) |
332
- | `generateSubset` | Requests DS-WEB fingerprinted WOFF2 and display subset generation from an existing WOFF2 via fontWorker |
333
- | `parseVariableFontInstances` | Resolves named variable font instances into Sanity font document references, creating documents for missing instances |
334
- | `getEmptyFontKit` | Returns a zeroed-out fontkit-shaped placeholder used when no font binary is available |
335
-
336
- ### Keyword utilities
337
-
338
- | Export | Description |
339
- |---|---|
340
- | `generateStyleKeywords` | Builds weight and italic keyword lists (including abbreviation expansions like `Bd → Bold`, `Lt → Light`) for parsing font subfamily names |
341
- | `reverseSpellingLookup` | Resolves a font name abbreviation to its canonical weight name |
342
- | `expandAbbreviations` | Expands all known abbreviations in a string to full weight names |
343
- | `removeWeightNames` | Strips weight and italic keywords from a string, leaving only non-style words |
344
-
345
- ### Constants
346
-
347
- | Export | Description |
348
- |---|---|
349
- | `SCRIPTS` | Array of supported script variant names |
350
- | `SCRIPTS_OBJECT` | Map of script names to their display labels |
351
- | `HtmlDescription` | React component rendering the supported script list as formatted HTML |
352
-
353
- ---
354
-
355
- ## Schema fields
356
-
357
- ### Font document (`font`)
358
-
359
- | Field | Type | Description |
360
- |---|---|---|
361
- | `title` | `string` | Full font name (e.g. `MyFont SemiBold Italic`) |
362
- | `slug` | `slug` | Sanitized document ID as a slug (`current` = document `_id`) |
363
- | `typefaceName` | `string` | Name of the parent typeface |
364
- | `style` | `string` | `'Regular'` or `'Italic'` |
365
- | `weight` | `number` | Numeric CSS weight (100–900) |
366
- | `weightName` | `string` | Human-readable weight name (e.g. `'SemiBold'`) |
367
- | `subfamily` | `string` | Subfamily name (e.g. `'Condensed'`) |
368
- | `variableFont` | `boolean` | `true` for variable fonts |
369
- | `normalWeight` | `boolean` | `true` when the weight is the normal/regular weight |
370
- | `fileInput` | `object` | Container for all uploaded format files |
371
- | `fileInput.ttf` | `file` | Uploaded TTF file (Sanity asset reference) |
372
- | `fileInput.otf` | `file` | OTF file (built from TTF or uploaded directly) |
373
- | `fileInput.woff2` | `file` | WOFF2 file (built from TTF or uploaded directly) |
374
- | `fileInput.woff` | `file` | WOFF file |
375
- | `fileInput.eot` | `file` | EOT file (legacy) |
376
- | `fileInput.svg` | `file` | SVG font file (legacy) |
377
- | `fileInput.css` | `file` | Generated `@font-face` CSS file |
378
- | `fileInput.woff2_web` | `file` | DS-WEB fingerprinted WOFF2 for web delivery |
379
- | `fileInput.woff2_subset` | `file` | Display subset WOFF2 (Latin + Latin-1, fingerprinted) |
380
- | `metaData` | `object` | Font metadata — `postscriptName`, `fullName`, `familyName`, `subfamilyName`, `copyright`, `version`, `genDate` |
381
- | `metrics` | `object` | Font metrics `unitsPerEm`, `ascender`, `descender`, `lineGap`, `capHeight`, `xHeight`, `italicAngle`, etc. |
382
- | `glyphCount` | `number` | Total number of glyphs |
383
- | `opentypeFeatures` | `object` | Available OpenType feature tags |
384
- | `characterSet` | `object` | Array of Unicode code points covered by the font |
385
- | `variableInstanceReferences` | `array<object>` | Maps variable font instance names to static font document references — `[{ key: string, value: reference }]` |
386
-
387
- ### Typeface document (`typeface`)
388
-
389
- | Field | Type | Description |
390
- |---|---|---|
391
- | `styles.fonts` | `array<reference>` | References to regular font documents |
392
- | `styles.variableFont` | `array<reference>` | References to variable font documents |
393
- | `styles.collections` | `array<reference>` | References to generated collection documents |
394
- | `styles.pairs` | `array<reference>` | References to generated pair documents |
395
- | `styles.subfamilies` | `array<object>` | Subfamily groups — each has `title`, `_key`, and `fonts: array<reference>` |
396
- | `preferredStyle` | `reference` | Reference to the preferred regular-weight font document |
397
-
398
- ---
399
-
400
- ## Environment variables
401
-
402
- | Variable | Required | Description |
403
- |---|---|---|
404
- | `SANITY_STUDIO_SITE_URL` | Yes | Base URL of the consuming site. Used by `generateFontFile` and `generateSubset` to call `/api/sanity/fontWorker`. |
405
- | `SANITY_STUDIO_PROJECT_ID` | Yes | Sanity project ID. Used to build CDN file URLs inside the uploaders. |
406
- | `SANITY_STUDIO_DATASET` | Yes | Sanity dataset name. Used alongside `PROJECT_ID` for CDN URLs. |
407
- | `SANITY_STUDIO_SCRIPTS` | No | Comma-separated script variant names (e.g. `latin,greek,arabic`). Controls which script tabs appear. |
408
- | `SANITY_STUDIO_DEFAULT_COLLECTION_PRICE` | No | Default per-font price for generated collections. |
409
- | `SANITY_STUDIO_DEFAULT_PAIR_PRICE` | No | Default price for generated pairs. |
410
-
411
- ---
412
-
413
- ## Local development
414
-
415
- To use the local source instead of the published npm package, symlink it into a foundry repo:
416
-
417
- ```bash
418
- # From the sanity-font-manager directory:
419
- npm run link:darden # symlink into Darden Studio
420
- npm run link:tdf # symlink into The Designers Foundry
421
- npm run link:mckl # symlink into MCKL CMS
422
- npm run link:all # symlink into all three at once
423
- ```
424
-
425
- Then run the watch build so consumers pick up changes live:
426
-
427
- ```bash
428
- npm run dev
429
- ```
430
-
431
- To restore the published package in a consumer repo, run `npm install` inside that repo.
432
-
433
- ---
434
-
435
- ## License
436
-
437
- MIT [Liiift Studio](https://github.com/Liiift-Studio)
1
+ # @liiift-studio/sanity-font-manager
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@liiift-studio/sanity-font-manager.svg)](https://www.npmjs.com/package/@liiift-studio/sanity-font-manager)
4
+ [![license](https://img.shields.io/npm/l/@liiift-studio/sanity-font-manager.svg)](#license)
5
+ [![Sanity v3 · v4 · v5](https://img.shields.io/badge/Sanity-v3%20%C2%B7%20v4%20%C2%B7%20v5-f03e2f.svg)](https://www.sanity.io/)
6
+
7
+ Full font management suite for Sanity Studio. Handles batch upload, multi-format conversion, metadata extraction, CSS `@font-face` generation, collection and pair generation, and script variant management.
8
+
9
+ Drag a folder of font files onto a typeface document and the plugin parses each file, detects its weight and style, checks for an existing document, then — after you review and confirm — uploads every format, generates the `@font-face` CSS and metadata, creates or updates the font documents, and maps any variable-font instances.
10
+
11
+ Compatible with Sanity v3, v4, and v5.
12
+
13
+ ## How it works
14
+
15
+ The upload flow is a two-phase **plan → execute** pipeline: phase 1 parses files and resolves duplicates with **reads only**, you review and edit the result, then phase 2 performs all **writes**. Font parsing runs on [`lib-font`](https://github.com/Pomax/lib-font) (WOFF/WOFF2 decompression is bootstrapped via `pako` + a vendored `unbrotli`), so no native binaries are required.
16
+
17
+ ```mermaid
18
+ flowchart TD
19
+ subgraph studio["Sanity Studio (typeface document)"]
20
+ BUF["BatchUploadFonts<br/>(schema input field)"]
21
+ BUF -->|opens| UM["UploadModal<br/>(lazy-loaded)"]
22
+ end
23
+
24
+ subgraph plan["Phase 1 Plan (reads only)"]
25
+ UM --> S1["Step 1 · Upload Files<br/>UploadStep1Settings"]
26
+ S1 --> BUILD["buildUploadPlan()"]
27
+ BUILD --> PF["parseFont() · lib-font<br/>setupDecompressors → pako + unbrotli"]
28
+ PF --> FH["fontHelpers<br/>weight / style / axes / metrics"]
29
+ FH --> RES["resolveExistingFont()<br/>exact / candidate / create"]
30
+ RES --> S2["Step 2 · Review<br/>FontReviewCard · BulkActions · ExistingDocumentResolver"]
31
+ end
32
+
33
+ subgraph execute["Phase 2 — Execute (writes)"]
34
+ S2 -->|user confirms| EXEC["executeUploadPlan()<br/>concurrency = 3 · 429 backoff + jitter"]
35
+ EXEC --> UP["Upload assets<br/>ttf / otf / woff / woff2"]
36
+ UP --> CSS["generateCssFile()<br/>@font-face + VF descriptors"]
37
+ CSS --> META["generateFontData()<br/>metadata · metrics · glyphs · features"]
38
+ META --> DOC["Create / update font documents"]
39
+ DOC --> PATCH["Patch typeface styles.fonts"]
40
+ PATCH --> S3b["Step 3b · Map Instances<br/>UploadStep3bInstances (variable fonts)"]
41
+ S3b --> SUM["Summary<br/>UploadSummary"]
42
+ end
43
+ ```
44
+
45
+ > The Mermaid source lives at [`assets/upload-pipeline.mmd`](assets/upload-pipeline.mmd) and renders inline on GitHub. **A maintainer screenshot or short GIF of the live upload modal** (drag → review table → execute → instance mapping) would make the workflow even clearer — these Studio components cannot be captured headlessly, so it is left as a follow-up. Drop the image into `assets/` and reference it with an absolute `raw.githubusercontent.com` URL.
46
+
47
+ ---
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ npm install @liiift-studio/sanity-font-manager
53
+ ```
54
+
55
+ ### Peer dependencies
56
+
57
+ ```bash
58
+ npm install sanity @sanity/ui @sanity/icons react @liiift-studio/sanity-advanced-reference-array
59
+ ```
60
+
61
+ | Peer | Required version | Notes |
62
+ |---|---|---|
63
+ | `sanity` | `>=3` | |
64
+ | `@sanity/ui` | `>=3` | |
65
+ | `@sanity/icons` | `>=3` | |
66
+ | `react` | `>=18` | |
67
+ | `@liiift-studio/sanity-advanced-reference-array` | `>=1` | **Required for the [Quickstart](#quickstart) path.** `createStylesField` and the typeface reference-array fields import it at module load, so importing them without it installed will throw. You only need it if you wire fields the manual way and skip both. |
68
+
69
+ If you hit peer dependency conflicts, add `legacy-peer-deps=true` to your `.npmrc`.
70
+
71
+ ### Parsing dependencies
72
+
73
+ Font parsing relies on [`lib-font`](https://github.com/Pomax/lib-font) and `pako` at runtime. If they are not already present in your studio's `node_modules`, install them alongside the plugin:
74
+
75
+ ```bash
76
+ npm install lib-font pako
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Quickstart
82
+
83
+ Wire `BatchUploadFonts` onto the `styles` field of a typeface document and you get the full drag-and-drop upload modal. The fastest way to build the rest of the `styles` object is the `createStylesField` factory, which assembles the fonts/variable-font/collections/pairs reference arrays for you.
84
+
85
+ ```jsx
86
+ // schemas/typeface.js
87
+ import {
88
+ BatchUploadFonts,
89
+ createStylesField,
90
+ openTypeField,
91
+ styleCountField,
92
+ } from '@liiift-studio/sanity-font-manager';
93
+
94
+ export const typeface = {
95
+ name: 'typeface',
96
+ type: 'document',
97
+ groups: [
98
+ { name: 'styles', title: 'Styles' },
99
+ { name: 'openType', title: 'Open Type' },
100
+ ],
101
+ fields: [
102
+ { name: 'title', type: 'string' },
103
+
104
+ // The drag-and-drop batch uploader lives on the styles object.
105
+ {
106
+ ...createStylesField({ generateCollections: true, pairs: true, styleCount: true }),
107
+ components: { input: BatchUploadFonts },
108
+ },
109
+
110
+ // Spreadable pre-built fields (optional):
111
+ styleCountField,
112
+ openTypeField,
113
+ ],
114
+ };
115
+ ```
116
+
117
+ Then set the required environment variables in your studio (`SANITY_STUDIO_SITE_URL`, `SANITY_STUDIO_PROJECT_ID`, `SANITY_STUDIO_DATASET` — see [Environment variables](#environment-variables)). Multi-format conversion and subsetting additionally need a `/api/sanity/fontWorker` endpoint on the consuming site (see [`generateFontFile`](#css-and-file-generation) / [`generateSubset`](#css-and-file-generation)); TTF/OTF/WOFF/WOFF2 upload, parsing, CSS, and metadata work without it.
118
+
119
+ Open a typeface document, drag font files onto the styles field, review the detected weights/styles, resolve any duplicates, and confirm. See [Upload workflow](#upload-workflow) for the full step-by-step.
120
+
121
+ ### Prerequisites the consumer provides
122
+
123
+ This plugin supplies the upload UI, parsing, and field factories — it writes to `font` and `typeface` document types that **your studio defines**. Use the [Schema fields](#schema-fields) tables below as the contract for the document shapes the uploaders read and patch (the `font` document's `fileInput`/`metaData`/`metrics` objects, the `typeface` document's `styles.fonts`/`styles.variableFont` arrays). `createStylesField` builds the `styles` object for you; the surrounding `typeface` and `font` document types are yours to declare.
124
+
125
+ ### The `fontWorker` endpoint (multi-format conversion / subsetting)
126
+
127
+ TTF/OTF/WOFF/WOFF2 upload, parsing, CSS, and metadata all work with no extra infrastructure. The additional conversions (`generateFontFile`, `generateSubset` — EOT/SVG and DS-WEB/subset WOFF2) are delegated to a `POST /api/sanity/fontWorker` route **you implement on the consuming site**, because the conversion runs server-side. The plugin sends a JSON body of:
128
+
129
+ ```jsonc
130
+ { "code": "generate-fonts", "srcUrl": "...", "filename": "...", "documentId": "...", "codes": ["woff", "eot", ...] }
131
+ ```
132
+
133
+ (plus `documentTitle`, `documentVariableFont`, `documentStyle`, `documentWeight`, `fileInput`, `language`). The endpoint URL is derived from `SANITY_STUDIO_SITE_URL`. The request is fire-and-forget (`mode: 'no-cors'`), so the worker writes the converted assets back to the Sanity document itself.
134
+
135
+ ---
136
+
137
+ ## Upload workflow
138
+
139
+ `BatchUploadFonts` opens `UploadModal`, a multi-step dialog that takes you from raw font files to linked Sanity documents. The flow is split into two phases — a preview you can edit, then the actual upload — so **nothing is written until you confirm**. Each step lists the component that implements it (in parentheses) for developers; the plain-language action comes first.
140
+
141
+ 1. **Drag in your fonts** — drop TTF/OTF/WOFF/WOFF2 files onto the styles field and set defaults like price and how filenames map to titles. *(`UploadStep1Settings`)*
142
+ 2. **Review what it detected** — the modal parses every file and shows a table of detected weight, style, and subfamily, all editable per font. Any font that already exists gets an **update existing vs create new** toggle so a re-upload updates the right document instead of duplicating it. This step only **reads** from Sanity. *(`UploadStep2Review` · `FontReviewCard` · `BulkActions` · `ExistingDocumentResolver`, driven by `buildUploadPlan` + `resolveExistingFont`)*
143
+ 3. **Confirm to upload** — on your confirmation the assets are uploaded, `@font-face` CSS and metadata are generated, font documents are created or updated, and the typeface's `styles.fonts` array is patched. Progress is shown per font. *(`UploadStep3Execute`, driven by `executeUploadPlan`)*
144
+ 4. **Map variable-font instances** — for a variable font, its named instances (e.g. *Light*, *Bold*) are matched to the matching static font documents; an **Autofill with Matching** action does this for you, and missing instances can be created. *(`UploadStep3bInstances` — see [`VariableInstanceReferencesInput`](#variableinstancereferencesinput))*
145
+ 5. **Review the summary** — a per-font created / updated / failed report. *(`UploadSummary`)*
146
+
147
+ ### Plan / Execute API
148
+
149
+ The pipeline is also exported as plain functions and reducers, so the upload engine can be driven or tested independently of the modal UI.
150
+
151
+ | Export | Description |
152
+ |---|---|
153
+ | `UploadModal` | The full multi-step modal dialog. Props: `open`, `onClose`, `client`, `docId`, `typefaceTitle`, `stylesObject`, `preferredStyleRef`, `slug`, `defaults`. Lazy-loaded by `BatchUploadFonts`. |
154
+ | `UploadStep1Settings`, `UploadStep2Review`, `UploadStep3Execute`, `UploadStep3bInstances`, `UploadSummary` | The individual step components, exported for custom modal layouts. |
155
+ | `FontReviewCard` | Collapsible per-font review row with editable weight/style/title/document-ID and a files indicator. |
156
+ | `BulkActions` | Sticky search/filter/expand-all bar for the review step, with create/update/error/conflict counts. |
157
+ | `ExistingDocumentResolver` | The update-existing vs create-new toggle and candidate picker for a single font. |
158
+ | `buildUploadPlan` | **Phase 1** — reads font files, parses with `lib-font`, resolves existing documents, and returns a complete `UploadPlan` for review. Performs Sanity reads only. |
159
+ | `executeUploadPlan` | **Phase 2** — uploads assets, generates CSS/metadata, creates/updates font documents, and patches the typeface. Skips fonts marked `error`; caches asset refs for idempotent retry. |
160
+ | `resolveExistingFont` | Resolves whether a font already exists — exact `_id`/slug match first, then content match by `typefaceName + weightName + style + subfamily + variableFont`. Returns `{ exact, candidates, recommendation, lookupFailed }`. |
161
+ | `planReducer` | Reducer driving the plan state machine (file processing, user edits, document resolution). |
162
+ | `executionReducer`, `createInitialExecutionState` | Reducer + initializer tracking per-font execution progress. |
163
+ | `createEmptyPlan`, `createFontDecisions` | Factories for an empty `UploadPlan` and a font's decision record. |
164
+ | `FONT_STATUS`, `PLAN_PHASE`, `RECOMMENDATION`, `EXECUTION_STATUS`, `PLAN_VERSION` | Enum constants for plan/execution state (single source of truth in `planTypes`). |
165
+
166
+ ---
167
+
168
+ ## Safety &amp; reliability
169
+
170
+ The upload engine is built to be pointed at a production dataset:
171
+
172
+ - **Review before write.** Phase 1 (`buildUploadPlan`) only **reads** — it parses files and looks up existing documents. No font document is created or modified until you confirm the plan and Phase 2 (`executeUploadPlan`) runs.
173
+ - **Duplicate resolution.** `resolveExistingFont` matches incoming fonts to existing documents (exact ID/slug, then content match) so a re-upload updates the right document instead of creating a duplicate.
174
+ - **Concurrency limit.** Asset uploads run at most `CONCURRENCY_LIMIT` (3) at a time.
175
+ - **Rate-limit handling.** `429` responses are retried up to `MAX_RETRIES` (3) with exponential backoff and ±25% jitter (`backoffWithJitter`), so large batches don't hammer the API. The retry is rate-limit-specific — other failures (a `500`, a network drop) fail that font rather than retrying, and it surfaces in the per-font summary.
176
+ - **Per-font isolation.** Uploads run via `Promise.allSettled`, so one font failing does not abort the rest of the batch; each font's outcome is tracked independently.
177
+ - **Idempotent retry.** Execution caches asset references in progress, so a partially failed batch can be retried without re-uploading assets that already succeeded; fonts marked `error` are skipped.
178
+ - **Long-upload guards.** The modal holds a Wake Lock and installs a `beforeunload` guard while executing, and confirms before closing mid-flight.
179
+
180
+ ## Testing
181
+
182
+ The package ships a [Vitest](https://vitest.dev/) suite under `src/tests/` covering the reducers (`planReducer`, `executionReducer`), plan types, document resolution, font parsing/metadata, CSS generation, keyword expansion, and a `lib-font` integration test with a mock font fixture.
183
+
184
+ ```bash
185
+ npm test # vitest run
186
+ npm run test:watch
187
+ ```
188
+
189
+ The `build` script (`npm run build`) runs the test suite before bundling with `tsup`, so a broken test blocks publish. (The suite — like the runtime — requires `lib-font` and `pako` in `node_modules`; see [Parsing dependencies](#parsing-dependencies).)
190
+
191
+ ---
192
+
193
+ ## Components
194
+
195
+ ### `BatchUploadFonts`
196
+
197
+ Drag-and-drop batch uploader for a typeface document. Accepts TTF/OTF/WOFF/WOFF2 etc., shows a reviewable file list with count, confirm button, elapsed timer, Wake Lock, and `beforeunload` guard for long uploads. Calls `uploadFontFiles` for each batch.
198
+
199
+ ```jsx
200
+ import { BatchUploadFonts } from '@liiift-studio/sanity-font-manager';
201
+
202
+ export const typefaceSchema = {
203
+ name: 'typeface',
204
+ type: 'document',
205
+ fields: [
206
+ {
207
+ name: 'styles',
208
+ type: 'object',
209
+ components: { input: BatchUploadFonts },
210
+ fields: [ /* see Schema fields below */ ],
211
+ },
212
+ ],
213
+ };
214
+ ```
215
+
216
+ ### `SingleUploaderTool`
217
+
218
+ Per-font file manager inside a font document. Shows TTF/OTF/WOFF/WOFF2/CSS rows always. EOT/SVG/WEB/SUBSET/DATA are hidden behind an advanced toggle (cog icon). Each row has Upload/Build/Delete controls. Handles CSS regeneration, font data extraction, and WEB+SUBSET building via fontWorker.
219
+
220
+ ```jsx
221
+ import { SingleUploaderTool } from '@liiift-studio/sanity-font-manager';
222
+
223
+ {
224
+ name: 'fileInput',
225
+ type: 'object',
226
+ components: { input: SingleUploaderTool },
227
+ fields: [ /* format fields — see Schema fields below */ ],
228
+ }
229
+ ```
230
+
231
+ ### `GenerateCollectionsPairsComponent`
232
+
233
+ One-click generator for Full Family, Uprights, Italics, and Subfamily collections, plus Regular/Italic pairs matched by weight. Has configurable price inputs for collection-per-font and pair price.
234
+
235
+ ```jsx
236
+ import { GenerateCollectionsPairsComponent } from '@liiift-studio/sanity-font-manager';
237
+ ```
238
+
239
+ ### `PrimaryCollectionGeneratorTypeface`
240
+
241
+ One-click generator for a single full-family collection that includes all fonts linked to the typeface. Prepends the new collection to the existing `styles.collections` array — non-destructive. Uses `SANITY_STUDIO_DEFAULT_COLLECTION_PRICE` as the default price, falling back to `100`.
242
+
243
+ Wire it up on a `string` field in the typeface schema:
244
+
245
+ ```jsx
246
+ import { PrimaryCollectionGeneratorTypeface } from '@liiift-studio/sanity-font-manager';
247
+
248
+ {
249
+ name: 'generateCollectionGroup',
250
+ type: 'string',
251
+ title: 'Generate Full Family Collection',
252
+ description: 'Generate a collection that includes all the styles from this typeface.',
253
+ components: { input: PrimaryCollectionGeneratorTypeface },
254
+ hidden: ({ parent }) => !parent?.styles?.fonts?.length,
255
+ }
256
+ ```
257
+
258
+ ### `FontScriptUploaderComponent`
259
+
260
+ Script-aware uploader for per-script font file variants (Latin, Arabic, Hebrew, etc.) stored in `scriptFileInput` on the font document.
261
+
262
+ ### `UploadScriptsComponent`
263
+
264
+ Batch uploader for script-specific font variants across multiple fonts at once.
265
+
266
+ ### `UpdateScriptsComponent`
267
+
268
+ Updates and re-links existing script font variant references on font documents — used to fix or reassign script variant assignments.
269
+
270
+ ### `RegenerateSubfamiliesComponent`
271
+
272
+ Recalculates and patches the `subfamily` field on all fonts linked to a typeface, based on the typeface's defined subfamily groups — without re-uploading any files.
273
+
274
+ ### `SetOTF`
275
+
276
+ Detects which configured OpenType feature keys are supported by the typeface's first linked font. Reads `opentypeFeatures.chars` from the font document (populated by `generateFontData`) and patches the `features` array on the field. Shows a feature count when features are detected, and clear error messages when font data is missing.
277
+
278
+ Wire it up on the `openType` object field in the typeface schema:
279
+
280
+ ```jsx
281
+ import { SetOTF } from '@liiift-studio/sanity-font-manager';
282
+
283
+ {
284
+ name: 'openType',
285
+ type: 'object',
286
+ components: { input: SetOTF },
287
+ options: { collapsible: true },
288
+ fields: [ /* feature fields — each with a `feature` string e.g. 'liga', 'smcp' */ ],
289
+ }
290
+ ```
291
+
292
+ ### `StyleCountInput`
293
+
294
+ Displays the total number of font styles (static + variable) linked to a typeface. Reads `styles.fonts` and `styles.variableFont` arrays from the form context. Useful as a read-only display field in the typeface schema.
295
+
296
+ ```jsx
297
+ import { StyleCountInput } from '@liiift-studio/sanity-font-manager';
298
+
299
+ {
300
+ name: 'styleCount',
301
+ type: 'number',
302
+ readOnly: true,
303
+ components: { input: StyleCountInput },
304
+ }
305
+ ```
306
+
307
+ ### `KeyValueInput`
308
+
309
+ Generic ordered key-value editor where both keys and values are plain strings. Supports add, remove, and reorder (up/down arrows). Values are stored as an array of `{ key, value }` objects.
310
+
311
+ ```jsx
312
+ import { KeyValueInput } from '@liiift-studio/sanity-font-manager';
313
+
314
+ {
315
+ name: 'aliases',
316
+ type: 'array',
317
+ of: [{ type: 'object', fields: [{ name: 'key', type: 'string' }, { name: 'value', type: 'string' }] }],
318
+ components: { input: KeyValueInput },
319
+ }
320
+ ```
321
+
322
+ ### `KeyValueReferenceInput`
323
+
324
+ Generic key-value editor where keys are plain strings and values are weak Sanity document references. Supports searching by title via a popover picker, add/remove/reorder, and an optional `topActions` slot for action buttons above the list.
325
+
326
+ | Prop | Type | Description |
327
+ |---|---|---|
328
+ | `fetchReferences` | `async (client, doc) => [{_id, title}]` | Async function that returns candidate references for the picker. Receives the Sanity client and the current document. |
329
+ | `topActions` | `ReactNode` | Optional content rendered above the key-value rows (e.g. autofill buttons). |
330
+ | `referenceType` | `string` | Document type for the created weak references (default: `'font'`). |
331
+
332
+ ```jsx
333
+ import { KeyValueReferenceInput } from '@liiift-studio/sanity-font-manager';
334
+
335
+ {
336
+ name: 'instanceMap',
337
+ type: 'array',
338
+ of: [{ type: 'object', fields: [{ name: 'key', type: 'string' }, { name: 'value', type: 'reference', weak: true, to: [{ type: 'font' }] }] }],
339
+ components: { input: KeyValueReferenceInput },
340
+ // Pass props via options or a wrapper component:
341
+ options: {
342
+ fetchReferences: async (client, doc) => client.fetch('*[_type == "font"]{_id, title}'),
343
+ referenceType: 'font',
344
+ },
345
+ }
346
+ ```
347
+
348
+ ### `VariableInstanceReferencesInput`
349
+
350
+ Font-specific wrapper around `KeyValueReferenceInput` for mapping variable font instance names to their matching static font documents. Provides:
351
+
352
+ - A picker filtered to fonts sharing the same `typefaceName`, excluding variable fonts
353
+ - **Autofill with Matching** — calls `parseVariableFontInstances` to match instance names to existing font documents by weight/style heuristics
354
+ - **Autofill Keys Only** — populates instance name keys from the font's `variableInstances` metadata without resolving references
355
+ - Autofill buttons are shown only when the document is a variable font with parsed instance data
356
+ - Replace/merge confirmation dialog when pairs already exist
357
+
358
+ ```jsx
359
+ import { VariableInstanceReferencesInput } from '@liiift-studio/sanity-font-manager';
360
+
361
+ {
362
+ name: 'variableInstanceReferences',
363
+ title: 'Variable Font Instances',
364
+ type: 'array',
365
+ hidden: ({ parent }) => !parent.variableFont,
366
+ of: [
367
+ {
368
+ type: 'object',
369
+ fields: [
370
+ { name: 'key', type: 'string', title: 'Instance Name' },
371
+ { name: 'value', type: 'reference', weak: true, to: [{ type: 'font' }], title: 'Matching Font' },
372
+ ],
373
+ },
374
+ ],
375
+ components: { input: VariableInstanceReferencesInput },
376
+ }
377
+ ```
378
+
379
+ ### `NestedObjectArraySelector`
380
+
381
+ Generic Sanity input that renders a searchable checkbox list of items pulled from a nested array field across documents of a given type (backed by [`useNestedObjects`](#usenestedobjects)). Configure entirely via schema `options`.
382
+
383
+ ```jsx
384
+ import { NestedObjectArraySelector } from '@liiift-studio/sanity-font-manager';
385
+
386
+ {
387
+ name: 'sections',
388
+ type: 'array',
389
+ of: [{ type: 'string' }],
390
+ components: { input: NestedObjectArraySelector },
391
+ options: {
392
+ sourceType: 'licenseGroup', // document type to query
393
+ nestedField: 'sections', // array field to extract
394
+ titleField: 'title', // GROQ expression for display text
395
+ valueField: 'slug.current', // GROQ expression for stored value
396
+ filter: 'state == "published"',// optional GROQ filter
397
+ sortBy: 'title asc', // optional sort
398
+ emptyMessage: 'No options found',
399
+ searchPlaceholder: 'Search...',
400
+ },
401
+ }
402
+ ```
403
+
404
+ ### `StatusDisplay`
405
+
406
+ Shared status bar used by all components. Shows `Status: [message]` in green on success and red on error, with an optional `action` element slot on the far right (used for the advanced toggle in `SingleUploaderTool`).
407
+
408
+ ```jsx
409
+ import { StatusDisplay } from '@liiift-studio/sanity-font-manager';
410
+
411
+ <StatusDisplay status="ready" error={false} action={<Button ... />} />
412
+ ```
413
+
414
+ ### `PriceInput`
415
+
416
+ Reusable `$` + number input for collection and pair price fields.
417
+
418
+ ### `UploadButton`
419
+
420
+ Label-wrapped button that triggers a hidden file input.
421
+
422
+ ---
423
+
424
+ ## Schema field definitions
425
+
426
+ Pre-built Sanity schema field objects that can be spread directly into a typeface schema's `fields` array. Eliminates hundreds of lines of repeated field definitions across consumer studios.
427
+
428
+ ### `createStylesField`
429
+
430
+ Factory that builds the complete `styles` object field — the fonts, variable-font, collections, and pairs reference arrays plus the subfamily groups — for a typeface document. This is the field `BatchUploadFonts` reads and writes (see [Quickstart](#quickstart)). Call it with options to toggle optional sub-fields:
431
+
432
+ ```js
433
+ import { createStylesField, BatchUploadFonts } from '@liiift-studio/sanity-font-manager';
434
+
435
+ {
436
+ ...createStylesField({ generateCollections: true, pairs: true, styleCount: true }),
437
+ components: { input: BatchUploadFonts },
438
+ }
439
+ ```
440
+
441
+ | Option | Default | Description |
442
+ |---|---|---|
443
+ | `pairs` | `true` | Include the Regular/Italic pairs reference array. |
444
+ | `generateCollections` | `false` | Include the collections array + `GenerateCollectionsPairsComponent`. |
445
+ | `generateFullFamilyCollection` | `false` | Include the full-family collection generator (`PrimaryCollectionGeneratorTypeface`). |
446
+ | `regenerateSubfamilies` | `false` | Include the `RegenerateSubfamiliesComponent` action. |
447
+ | `styleCount` | `false` | Inject the read-only style-count field (`StyleCountInput`). |
448
+ | `displayStyles`, `free`, `serif`, `sortHeaviestFirst`, `buySectionColumns`, `fontSizeMultiplier`, `subfamily*` | various | Storefront/display toggles — see the source for the full set. |
449
+
450
+ > Uses `@liiift-studio/sanity-advanced-reference-array` (a peer dependency — see [Peer dependencies](#peer-dependencies)) for the typeface-scoped reference pickers.
451
+
452
+ ### `openTypeField`
453
+
454
+ A complete `openType` object field wired to the `openType` tab group. Includes the `features` checkbox array (all standard OpenType feature keys) plus per-feature sub-objects with `title`, `feature`, and `customText` fields. Uses `SetOTF` internally for auto-detection.
455
+
456
+ ```js
457
+ import { openTypeField } from '@liiift-studio/sanity-font-manager';
458
+
459
+ // In your typeface schema fields array:
460
+ openTypeField,
461
+ ```
462
+
463
+ Requires the `openType` group to be declared in your schema's `groups` array:
464
+ ```js
465
+ { name: 'openType', title: 'Open Type' }
466
+ ```
467
+
468
+ ### `createOpenTypeField`
469
+
470
+ Factory variant of `openTypeField`. Pass `{ customText: true }` to reveal a per-feature `customText` input on every feature object; pass `{ customText: true, customTextType: 'code' }` to make it a syntax-highlighted HTML `code` field (for `<span>`-wrapped sample text). Returns a plain `openTypeField` when `customText` is `false`.
471
+
472
+ ```js
473
+ import { createOpenTypeField } from '@liiift-studio/sanity-font-manager';
474
+
475
+ // In your typeface schema fields array:
476
+ createOpenTypeField({ customText: true, customTextType: 'code' }),
477
+ ```
478
+
479
+ ### `styleCountField`
480
+
481
+ A read-only `number` field in the `styles` group that displays the total count of static + variable font styles linked to the typeface. Uses `StyleCountInput` internally.
482
+
483
+ ```js
484
+ import { styleCountField } from '@liiift-studio/sanity-font-manager';
485
+
486
+ // In your typeface schema fields array:
487
+ styleCountField,
488
+ ```
489
+
490
+ ### `stylisticSetField`
491
+
492
+ A complete `stylisticSet` object field for the `stylisticSets` group. Contains two sub-arrays: `featured` (highlighted words/phrases with per-character backtick syntax, stylistic feature picker, size, and CSS overrides) and `sets` (full catalogue of feature → glyph mappings). Both include the full OpenType feature dropdown (44 named features + all 20 stylistic sets).
493
+
494
+ ```js
495
+ import { stylisticSetField } from '@liiift-studio/sanity-font-manager';
496
+
497
+ // In your typeface schema fields array:
498
+ stylisticSetField,
499
+ ```
500
+
501
+ Requires the `stylisticSets` group to be declared in your schema's `groups` array:
502
+ ```js
503
+ { name: 'stylisticSets', title: 'Stylistic Sets' }
504
+ ```
505
+
506
+ ---
507
+
508
+ ## Hook
509
+
510
+ ### `useSanityClient`
511
+
512
+ Returns the Sanity client instance from the studio context. Used internally by all components.
513
+
514
+ ```js
515
+ import { useSanityClient } from '@liiift-studio/sanity-font-manager';
516
+
517
+ const client = useSanityClient();
518
+ ```
519
+
520
+ ### `useNestedObjects`
521
+
522
+ Fetches and flattens a nested array field across documents of a given type into a flat list of selectable items. Backs `NestedObjectArraySelector`.
523
+
524
+ ```js
525
+ import { useNestedObjects } from '@liiift-studio/sanity-font-manager';
526
+
527
+ const { objects, loading, error } = useNestedObjects({
528
+ sourceType: 'licenseGroup', // document type to query
529
+ nestedField: 'sections', // array field to extract
530
+ titleField: 'title', // GROQ expression for display text
531
+ valueField: 'slug.current', // GROQ expression for stored value
532
+ filter: 'state == "published"', // optional GROQ filter
533
+ sortBy: 'title asc', // optional sort
534
+ });
535
+ ```
536
+
537
+ ---
538
+
539
+ ## Utilities
540
+
541
+ ### Font parsing (lib-font)
542
+
543
+ Parsing runs on [`lib-font`](https://github.com/Pomax/lib-font). `parseFont` is the single entry point; the `fontHelpers` wrappers are the only code that touches `font.opentype.tables.*`. `setupDecompressors` registers `globalThis.pako` (WOFF) and `globalThis.unbrotli` (WOFF2) and is imported as a side effect by the package entry point and by `parseFont` — **import order matters**, so do not import `lib-font` directly before it.
544
+
545
+ | Export | Description |
546
+ |---|---|
547
+ | `parseFont` | `async (buffer, filename) => Font` — parses an `ArrayBuffer` into a `lib-font` `Font`. Enforces a 50 MB size limit and a 30 s timeout; throws on oversize/corrupt/timeout. |
548
+ | `getNameString` | Reads a name-table string by numeric name ID, preferring Windows/Unicode/English then Mac/Roman, with a per-font cache. |
549
+ | `getAllFeatureTags` | All OpenType feature tags from GSUB/GPOS (equivalent to fontkit's `availableFeatures`). |
550
+ | `getCharacterSet` | Array of Unicode code points covered by the font. |
551
+ | `getVariationAxes` | Variation-axis map for variable fonts (`min`/`default`/`max` per axis). |
552
+ | `getNamedInstances` | Named instances of a variable font. |
553
+ | `getFontMetrics` | `unitsPerEm`, ascender/descender, cap/x-height, italic angle, etc. |
554
+ | `getFontMetadata` | `postscriptName`, `fullName`, `familyName`, `subfamilyName`, `copyright`, `version`. |
555
+ | `getWeightClass` | OS/2 `usWeightClass`. |
556
+ | `getFsSelection`, `getMacStyle`, `getItalicAngle`, `getGlyphCount`, `getFamilyClass` | Lower-level table accessors. |
557
+ | `escapeCssFontName` | Escapes a font family name for safe use in CSS. |
558
+
559
+ ### Font processing
560
+
561
+ | Export | Description |
562
+ |---|---|
563
+ | `processFontFiles` | Reads font files via FileReader, parses with `lib-font` (via `parseFont`), and builds the `fontsObjects` map used by `uploadFontFiles` |
564
+ | `extractFontMetadata` | Extracts weight name, subfamily, style, and variable font flag from a `lib-font` parsed font |
565
+ | `extractWeightName` | Reads the weight name from `lib-font` name records, falling back through `preferredSubfamily → fontSubfamily` |
566
+ | `extractWeightFromFullName` | Strips the typeface title from the font's full name to isolate the weight/style suffix |
567
+ | `processSubfamilyName` | Strips weight and italic keywords from a subfamily string, preserving non-style words like "Condensed" |
568
+ | `formatFontTitle` | Normalises a font filename into a human-readable title — expands abbreviations, title-cases, collapses spaces |
569
+ | `addItalicToFontTitle` | Appends the detected italic keyword to a title when the font has a non-zero italic angle |
570
+ | `determineWeight` | Maps a weight name to a CSS numeric weight, preferring OS/2 `usWeightClass` when available |
571
+ | `sortFontObjects` | Sorts a `fontsObjects` map by ascending weight, placing Regular before Italic at equal weights |
572
+ | `createFontObject` | Builds the full font object (id, title, weight, style, files, etc.) for a single font file |
573
+ | `uploadFontFiles` | Core batch upload orchestrator — uploads each format to Sanity, generates CSS and metadata, then creates or updates font documents |
574
+ | `updateTypefaceDocument` | Patches the parent typeface document's `styles.fonts` array with newly uploaded font references |
575
+ | `renameFontDocuments` | Renames font document IDs across a typeface when a typeface slug changes |
576
+ | `updateFontPrices` | Bulk-updates the `price` field across all font documents linked to a typeface |
577
+ | `sanitizeForSanityId` | Converts arbitrary strings into valid Sanity document IDs (lowercase, hyphens, no special characters) |
578
+
579
+ ### CSS and file generation
580
+
581
+ | Export | Description |
582
+ |---|---|
583
+ | `generateCssFile` | Builds a `@font-face` CSS file from a WOFF2 blob — URL or base64 `src`, variable font axis descriptors, and metric-tuned fallback `@font-face` for CLS reduction |
584
+ | `buildVFDescriptors` | Pure function — maps variation axes (from `getVariationAxes`) to CSS descriptors (`font-weight`, `font-stretch`, `font-style`), handling degenerate axes, `slnt`/`ital` priority, and `min > max` clamping |
585
+ | `generateFontData` | Fetches a TTF URL, parses with `lib-font`, and patches the Sanity font document with `metaData`, `metrics`, `glyphCount`, `opentypeFeatures`, `characterSet`, and variable axes/instances |
586
+ | `buildFontMetadata` | Pure function — extracts `metaData` and `metrics` from a `lib-font` parsed font without any Sanity side effects |
587
+ | `generateFontFile` | Fires a POST to the consuming site's `/api/sanity/fontWorker` endpoint with the format codes to convert (otf, woff, woff2, eot, svg, data) |
588
+ | `generateSubset` | Requests DS-WEB fingerprinted WOFF2 and display subset generation from an existing WOFF2 via fontWorker |
589
+ | `parseVariableFontInstances` | Resolves named variable font instances into Sanity font document references, creating documents for missing instances |
590
+ | `getEmptyFontKit` | Returns a zeroed-out placeholder font object used when no font binary is available |
591
+
592
+ ### Keyword utilities
593
+
594
+ | Export | Description |
595
+ |---|---|
596
+ | `generateStyleKeywords` | Builds weight and italic keyword lists (including abbreviation expansions like `Bd → Bold`, `Lt → Light`) for parsing font subfamily names |
597
+ | `reverseSpellingLookup` | Resolves a font name abbreviation to its canonical weight name |
598
+ | `expandAbbreviations` | Expands all known abbreviations in a string to full weight names |
599
+ | `removeWeightNames` | Strips weight and italic keywords from a string, leaving only non-style words |
600
+
601
+ ### Constants
602
+
603
+ | Export | Description |
604
+ |---|---|
605
+ | `SCRIPTS` | Array of supported script variant names |
606
+ | `SCRIPTS_OBJECT` | Map of script names to their display labels |
607
+ | `HtmlDescription` | React component rendering the supported script list as formatted HTML |
608
+ | `DISCOUNT_REQUIREMENT_TYPES` | Array of supported discount-requirement type names |
609
+ | `DISCOUNT_REQUIREMENT_TYPES_OBJECT` | Map of discount-requirement type names to their display labels |
610
+
611
+ ---
612
+
613
+ ## Schema fields
614
+
615
+ ### Font document (`font`)
616
+
617
+ | Field | Type | Description |
618
+ |---|---|---|
619
+ | `title` | `string` | Full font name (e.g. `MyFont SemiBold Italic`) |
620
+ | `slug` | `slug` | Sanitized document ID as a slug (`current` = document `_id`) |
621
+ | `typefaceName` | `string` | Name of the parent typeface |
622
+ | `style` | `string` | `'Regular'` or `'Italic'` |
623
+ | `weight` | `number` | Numeric CSS weight (100–900) |
624
+ | `weightName` | `string` | Human-readable weight name (e.g. `'SemiBold'`) |
625
+ | `subfamily` | `string` | Subfamily name (e.g. `'Condensed'`) |
626
+ | `variableFont` | `boolean` | `true` for variable fonts |
627
+ | `normalWeight` | `boolean` | `true` when the weight is the normal/regular weight |
628
+ | `fileInput` | `object` | Container for all uploaded format files |
629
+ | `fileInput.ttf` | `file` | Uploaded TTF file (Sanity asset reference) |
630
+ | `fileInput.otf` | `file` | OTF file (built from TTF or uploaded directly) |
631
+ | `fileInput.woff2` | `file` | WOFF2 file (built from TTF or uploaded directly) |
632
+ | `fileInput.woff` | `file` | WOFF file |
633
+ | `fileInput.eot` | `file` | EOT file (legacy) |
634
+ | `fileInput.svg` | `file` | SVG font file (legacy) |
635
+ | `fileInput.css` | `file` | Generated `@font-face` CSS file |
636
+ | `fileInput.woff2_web` | `file` | DS-WEB fingerprinted WOFF2 for web delivery |
637
+ | `fileInput.woff2_subset` | `file` | Display subset WOFF2 (Latin + Latin-1, fingerprinted) |
638
+ | `metaData` | `object` | Font metadata — `postscriptName`, `fullName`, `familyName`, `subfamilyName`, `copyright`, `version`, `genDate` |
639
+ | `metrics` | `object` | Font metrics — `unitsPerEm`, `ascender`, `descender`, `lineGap`, `capHeight`, `xHeight`, `italicAngle`, etc. |
640
+ | `glyphCount` | `number` | Total number of glyphs |
641
+ | `opentypeFeatures` | `object` | Available OpenType feature tags |
642
+ | `characterSet` | `object` | Array of Unicode code points covered by the font |
643
+ | `variableInstanceReferences` | `array<object>` | Maps variable font instance names to static font document references — `[{ key: string, value: reference }]` |
644
+
645
+ ### Typeface document (`typeface`)
646
+
647
+ | Field | Type | Description |
648
+ |---|---|---|
649
+ | `styles.fonts` | `array<reference>` | References to regular font documents |
650
+ | `styles.variableFont` | `array<reference>` | References to variable font documents |
651
+ | `styles.collections` | `array<reference>` | References to generated collection documents |
652
+ | `styles.pairs` | `array<reference>` | References to generated pair documents |
653
+ | `styles.subfamilies` | `array<object>` | Subfamily groups — each has `title`, `_key`, and `fonts: array<reference>` |
654
+ | `preferredStyle` | `reference` | Reference to the preferred regular-weight font document |
655
+
656
+ ---
657
+
658
+ ## Environment variables
659
+
660
+ | Variable | Required | Description |
661
+ |---|---|---|
662
+ | `SANITY_STUDIO_SITE_URL` | Yes | Base URL of the consuming site. Used by `generateFontFile` and `generateSubset` to call `/api/sanity/fontWorker`. |
663
+ | `SANITY_STUDIO_PROJECT_ID` | Yes | Sanity project ID. Used to build CDN file URLs inside the uploaders. |
664
+ | `SANITY_STUDIO_DATASET` | Yes | Sanity dataset name. Used alongside `PROJECT_ID` for CDN URLs. |
665
+ | `SANITY_STUDIO_SCRIPTS` | No | Comma-separated script variant names (e.g. `latin,greek,arabic`). Controls which script tabs appear. |
666
+ | `SANITY_STUDIO_DEFAULT_COLLECTION_PRICE` | No | Default per-font price for generated collections. |
667
+ | `SANITY_STUDIO_DEFAULT_PAIR_PRICE` | No | Default price for generated pairs. |
668
+
669
+ ---
670
+
671
+ ## Local development
672
+
673
+ To use the local source instead of the published npm package, symlink it into a foundry repo:
674
+
675
+ ```bash
676
+ # From the sanity-font-manager directory:
677
+ npm run link:darden # symlink into Darden Studio
678
+ npm run link:tdf # symlink into The Designers Foundry
679
+ npm run link:mckl # symlink into MCKL CMS
680
+ npm run link:all # symlink into all three at once
681
+ ```
682
+
683
+ Then run the watch build so consumers pick up changes live:
684
+
685
+ ```bash
686
+ npm run dev
687
+ ```
688
+
689
+ To restore the published package in a consumer repo, run `npm install` inside that repo.
690
+
691
+ ---
692
+
693
+ ## License
694
+
695
+ [MIT](LICENSE) — [Liiift Studio](https://github.com/Liiift-Studio)